(ns metabase.actions.http-action (:require [clj-http.client :as http] [clojure.string :as str] [metabase.driver.common.parameters :as params] [metabase.driver.common.parameters.parse :as params.parse] [metabase.query-processor.error-type :as qp.error-type] [metabase.util :as u] [metabase.util.i18n :refer [tru]] [metabase.util.json :as json] [metabase.util.log :as log]) (:import (com.fasterxml.jackson.databind ObjectMapper) (net.thisptr.jackson.jq BuiltinFunctionLoader JsonQuery Output Scope Versions))) | |
(set! *warn-on-reflection* true) | |
(defonce ^:private root-scope
(delay
(let [scope (Scope/newEmptyScope)]
(.loadFunctions (BuiltinFunctionLoader/getInstance) Versions/JQ_1_6 scope)))) | |
(defonce ^:private object-mapper (delay (ObjectMapper.))) | |
Largely copied from sql drivers param substitute. May go away if parameters substitution is taken out of query-processing/db dependency | (declare substitute*) |
(defn- substitute-param [param->value [sql missing] _in-optional? {:keys [k]}]
(if-not (contains? param->value k)
[sql (conj missing k)]
(let [v (get param->value k)]
(cond
(= params/no-value v)
[sql (conj missing k)]
:else
[(str sql v) missing])))) | |
(defn- substitute-optional [param->value [sql missing] {subclauses :args}]
(let [[opt-sql opt-missing] (substitute* param->value subclauses true)]
(if (seq opt-missing)
[sql missing]
[(str sql opt-sql) missing]))) | |
Returns a sequence of | (defn- substitute*
[param->value parsed in-optional?]
(reduce
(fn [[sql missing] x]
(cond
(string? x)
[(str sql x) missing]
(params/Param? x)
(substitute-param param->value [sql missing] in-optional? x)
(params/Optional? x)
(substitute-optional param->value [sql missing] x)))
nil
parsed)) |
Substitute (substitute ["https://example.com/?filter=" (param "bird_type")] {"bird_type" "Steller's Jay"}) ;; -> "https://example.com/?filter=Steller's Jay" | (defn substitute
[parsed-template param->value]
(log/tracef "Substituting params\n%s\nin template\n%s" (u/pprint-to-str param->value) (u/pprint-to-str parsed-template))
(let [[sql missing] (try
(substitute* param->value parsed-template false)
(catch Throwable e
(throw (ex-info (tru "Unable to substitute parameters: {0}" (ex-message e))
{:type (or (:type (ex-data e)) qp.error-type/qp)
:params param->value
:parsed-query parsed-template}
e))))]
(log/tracef "=>%s" sql)
(when (seq missing)
(throw (ex-info (tru "Cannot call the service: missing required parameters: {0}" (str/join ", " (set missing)))
{:type qp.error-type/missing-required-parameter
:missing missing
:status-code 400})))
(str/trim sql))) |
(defn- parse-and-substitute [s params->value]
(when s
(-> s
params.parse/parse
(substitute params->value)))) | |
(deftype ActionOutput [results]
Output
(emit [_ x]
(vswap! results conj (str x)))) | |
Executes a jq query on [[object]]. | (defn apply-json-query
[object jq-query]
;; TODO this is pretty ineficient. We parse with `:as :json`, then reencode within a response
;; I couldn't find a way to get JSONNode out of cheshire, so we fall back to jackson.
;; Should jackson be added explicitly to deps.edn?
(let [json-node (.readTree ^ObjectMapper @object-mapper (json/encode object))
vresults (volatile! [])
output (ActionOutput. vresults)
expr (JsonQuery/compile jq-query Versions/JQ_1_6)
;; might need to Scope childScope = Scope.newChildScope(rootScope); if root-scope can be modified by expression
_ (.apply expr @root-scope json-node output)
results @vresults]
(if (<= (count results) 1)
(first results)
(throw (ex-info (tru "Too many results returned: {0}" (pr-str results)) {:jq-query jq-query :results results}))))) |
Calls an http endpoint based on action and params | (defn execute-http-action!
[action params->value]
(try
(let [{:keys [method url body headers]} (:template action)
request {:method (keyword method)
:url (parse-and-substitute url params->value)
:accept :json
:content-type :json
:throw-exceptions false
:headers (merge
;; TODO maybe we want to default Agent here? Maybe Origin/Referer?
{"X-Metabase-Action" (:name action)}
(-> headers
(parse-and-substitute params->value)
(json/decode)))
:body (parse-and-substitute body params->value)}
response (-> (http/request request)
(select-keys [:body :headers :status])
(update :body json/decode))
error (json/decode (apply-json-query response (or (:error_handle action) ".status >= 400")))]
(log/trace "Response before handle:" response)
(if error
{:status 400
:headers {"Content-Type" "application/json"}
:body (if (boolean? error)
{:remote-status (:status response)
;; this is a very lazy approach to get some string identifying the error from the incoming data,
;; just to pass `dashcard-http-action-execution-test`; ideally the whole logic here should be
;; updated to pass enough details back to the client
:message (let [body (:body response)]
(or (get body "error")
(get body "error-code")
(get body "message")))}
error)}
(if-some [response (some->> action :response_handle (apply-json-query response))]
{:status 200
:headers {"Content-Type" "application/json"}
:body response}
{:status 204
:body nil})))
(catch Exception e
(throw (ex-info (str "Problem building request: " (ex-message e))
{:template (:template action)
:status-code (:status-code (ex-data e) 500)}
e))))) |