Skip to content
Snippets Groups Projects
Commit 3fce8428 authored by Cam Saül's avatar Cam Saül Committed by GitHub
Browse files

Merge pull request #4922 from metabase/stefano-excel-export

Add Option to download Excel spreadsheets
parents 28bf5aa5 147b1742
No related branches found
No related tags found
No related merge requests found
Showing with 197 additions and 131 deletions
......@@ -13,6 +13,8 @@ import * as Urls from "metabase/lib/urls";
import _ from "underscore";
import cx from "classnames";
const EXPORT_FORMATS = ["csv", "xlsx", "json"];
const QueryDownloadWidget = ({ className, card, result, uuid, token }) =>
<PopoverWithTrigger
triggerElement={
......@@ -22,7 +24,7 @@ const QueryDownloadWidget = ({ className, card, result, uuid, token }) =>
}
triggerClasses={cx(className, "text-brand-hover")}
>
<div className="p2" style={{ maxWidth: 300 }}>
<div className="p2" style={{ maxWidth: 320 }}>
<h4>Download</h4>
{ result.data.rows_truncated != null &&
<FieldSet className="my2 text-gold border-gold" legend="Warning">
......@@ -31,7 +33,7 @@ const QueryDownloadWidget = ({ className, card, result, uuid, token }) =>
</FieldSet>
}
<div className="flex flex-row mt2">
{["csv", "json"].map(type =>
{EXPORT_FORMATS.map(type =>
uuid ?
<PublicQueryButton key={type} type={type} uuid={uuid} className="mr1 text-uppercase text-default" />
: token ?
......
......@@ -33,7 +33,7 @@ export default class QuestionEmbedWidget extends Component {
onUpdateEnableEmbedding={(enableEmbedding) => updateEnableEmbedding(card, enableEmbedding)}
onUpdateEmbeddingParams={(embeddingParams) => updateEmbeddingParams(card, embeddingParams)}
getPublicUrl={({ public_uuid }, extension) => window.location.origin + Urls.publicCard(public_uuid, extension)}
extensions={["csv", "json"]}
extensions={["csv", "xlsx", "json"]}
/>
);
}
......
......@@ -54,6 +54,7 @@
[com.taoensso/nippy "2.13.0"] ; Fast serialization (i.e., GZIP) library for Clojure
[compojure "1.5.2"] ; HTTP Routing library built on Ring
[crypto-random "1.2.0"] ; library for generating cryptographically secure random bytes and strings
[dk.ative/docjure "1.11.0"] ; Excel export
[environ "1.1.0"] ; easy environment management
[hiccup "1.0.5"] ; HTML templating
[honeysql "0.8.2"] ; Transform Clojure data structures to SQL
......
......@@ -414,26 +414,17 @@
(binding [cache/*ignore-cached-results* ignore_cache]
(run-query-for-card card-id, :parameters parameters)))
(api/defendpoint POST "/:card-id/query/csv"
"Run the query associated with a Card, and return its results as CSV. Note that this expects the parameters as serialized JSON in the 'parameters' parameter"
[card-id parameters]
{parameters (s/maybe su/JSONString)}
(api/defendpoint POST "/:card-id/query/:export-format"
"Run the query associated with a Card, and return its results as a file in the specified format. Note that this expects the parameters as serialized JSON in the 'parameters' parameter"
[card-id export-format parameters]
{parameters (s/maybe su/JSONString)
export-format dataset-api/export-format-schema}
(binding [cache/*ignore-cached-results* true]
(dataset-api/as-csv (run-query-for-card card-id
:parameters (json/parse-string parameters keyword)
:constraints nil
:context :csv-download))))
(api/defendpoint POST "/:card-id/query/json"
"Run the query associated with a Card, and return its results as JSON. Note that this expects the parameters as serialized JSON in the 'parameters' parameter"
[card-id parameters]
{parameters (s/maybe su/JSONString)}
(binding [cache/*ignore-cached-results* true]
(dataset-api/as-json (run-query-for-card card-id
:parameters (json/parse-string parameters keyword)
:constraints nil
:context :json-download))))
(dataset-api/as-format export-format
(run-query-for-card card-id
:parameters (json/parse-string parameters keyword)
:constraints nil
:context (dataset-api/export-format->context export-format)))))
;;; ------------------------------------------------------------ Sharing is Caring ------------------------------------------------------------
......
......@@ -2,7 +2,9 @@
"/api/dataset endpoints."
(:require [cheshire.core :as json]
[clojure.data.csv :as csv]
[clojure.string :as string]
[compojure.core :refer [POST]]
[dk.ative.docjure.spreadsheet :as spreadsheet]
[metabase
[query-processor :as qp]
[util :as u]]
......@@ -11,7 +13,9 @@
[database :refer [Database]]
[query :as query]]
[metabase.query-processor.util :as qputil]
[metabase.util.schema :as su]))
[metabase.util.schema :as su]
[schema.core :as s])
(:import [java.io ByteArrayInputStream ByteArrayOutputStream]))
(def ^:private ^:const max-results-bare-rows
"Maximum number of rows to return specifically on :rows type queries via the API."
......@@ -45,51 +49,75 @@
(query/average-execution-time-ms (qputil/query-hash (assoc query :constraints default-query-constraints)))
0)})
(defn as-csv
"Return a CSV response containing the RESULTS of a query."
{:arglists '([results])}
[{{:keys [columns rows]} :data, :keys [status], :as response}]
(if (= status :completed)
;; successful query, send CSV file
{:status 200
:body (with-out-str
;; turn keywords into strings, otherwise we get colons in our output
(csv/write-csv *out* (into [(mapv name columns)] rows)))
:headers {"Content-Type" "text/csv; charset=utf-8"
"Content-Disposition" (str "attachment; filename=\"query_result_" (u/date->iso-8601) ".csv\"")}}
;; failed query, send error message
{:status 500
:body (:error response)}))
(defn as-json
"Return a JSON response containing the RESULTS of a query."
{:arglists '([results])}
[{{:keys [columns rows]} :data, :keys [status], :as response}]
(if (= status :completed)
;; successful query, send CSV file
{:status 200
:body (for [row rows]
(zipmap columns row))
:headers {"Content-Disposition" (str "attachment; filename=\"query_result_" (u/date->iso-8601) ".json\"")}}
;; failed query, send error message
{:status 500
:body {:error (:error response)}}))
(api/defendpoint POST "/csv"
"Execute a query and download the result data as a CSV file."
[query]
{query su/JSONString}
(let [query (json/parse-string query keyword)]
(api/read-check Database (:database query))
(as-csv (qp/dataset-query (dissoc query :constraints) {:executed-by api/*current-user-id*, :context :csv-download}))))
(defn- export-to-csv [columns rows]
(with-out-str
;; turn keywords into strings, otherwise we get colons in our output
(csv/write-csv *out* (into [(mapv name columns)] rows))))
(defn- export-to-xlsx [columns rows]
(let [wb (spreadsheet/create-workbook "Query result" (conj rows (mapv name columns)))
;; note: byte array streams don't need to be closed
out (ByteArrayOutputStream.)]
(spreadsheet/save-workbook! out wb)
(ByteArrayInputStream. (.toByteArray out))))
(defn- export-to-json [columns rows]
(for [row rows]
(zipmap columns row)))
(def ^:private export-formats
{"csv" {:export-fn export-to-csv
:content-type "text/csv"
:ext "csv"
:context :csv-download},
"xlsx" {:export-fn export-to-xlsx
:content-type "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
:ext "xlsx"
:context :xlsx-download},
"json" {:export-fn export-to-json
:content-type "applicaton/json"
:ext "json"
:context :json-download}})
(def export-format-schema
"Schema for valid export formats for downloading query results."
(apply s/enum (keys export-formats)))
(defn export-format->context
"Return the `:context` that should be used when saving a QueryExecution triggered by a request to download results in EXPORT-FORAMT.
(export-format->context :json) ;-> :json-download"
[export-format]
(or (get-in export-formats [export-format :context])
(throw (Exception. (str "Invalid export format: " export-format)))))
(defn as-format
"Return a response containing the RESULTS of a query in the specified format."
{:style/indent 1, :arglists '([export-format results])}
[export-format {{:keys [columns rows]} :data, :keys [status], :as response}]
(api/let-404 [export-conf (export-formats export-format)]
(if (= status :completed)
;; successful query, send file
{:status 200
:body ((:export-fn export-conf) columns rows)
:headers {"Content-Type" (str (:content-type export-conf) "; charset=utf-8")
"Content-Disposition" (str "attachment; filename=\"query_result_" (u/date->iso-8601) "." (:ext export-conf) "\"")}}
;; failed query, send error message
{:status 500
:body (:error response)})))
(def ^:private export-format-regex (re-pattern (str "(" (string/join "|" (keys export-formats)) ")")))
(api/defendpoint POST "/json"
"Execute a query and download the result data as a JSON file."
[query]
{query su/JSONString}
(api/defendpoint 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 query]
{query su/JSONString
export-format export-format-schema}
(let [query (json/parse-string query keyword)]
(api/read-check Database (:database query))
(as-json (qp/dataset-query (dissoc query :constraints) {:executed-by api/*current-user-id*, :context :json-download}))))
(as-format export-format
(qp/dataset-query (dissoc query :constraints)
{:executed-by api/*current-user-id*, :context (export-format->context export-format)}))))
(api/define-routes)
......@@ -276,16 +276,12 @@
(run-query-for-unsigned-token (eu/unsign token) query-params))
(api/defendpoint GET "/card/:token/query/csv"
"Like `GET /api/embed/card/query`, but returns the results as CSV."
[token & query-params]
(dataset-api/as-csv (run-query-for-unsigned-token (eu/unsign token) query-params, :constraints nil)))
(api/defendpoint GET "/card/:token/query/json"
"Like `GET /api/embed/card/query`, but returns the results as JSOn."
[token & query-params]
(dataset-api/as-json (run-query-for-unsigned-token (eu/unsign token) query-params, :constraints nil)))
(api/defendpoint GET "/card/:token/query/:export-format"
"Like `GET /api/embed/card/query`, but returns the results as a file in the specified format."
[token export-format & query-params]
{export-format dataset-api/export-format-schema}
(dataset-api/as-format export-format
(run-query-for-unsigned-token (eu/unsign token) query-params, :constraints nil)))
;;; ------------------------------------------------------------ /api/embed/dashboard endpoints ------------------------------------------------------------
......
......@@ -114,18 +114,13 @@
{parameters (s/maybe su/JSONString)}
(run-query-for-card-with-public-uuid uuid parameters))
(api/defendpoint GET "/card/:uuid/query/json"
"Fetch a publically-accessible Card and return query results as JSON. Does not require auth credentials. Public sharing must be enabled."
[uuid parameters]
{parameters (s/maybe su/JSONString)}
(dataset-api/as-json (run-query-for-card-with-public-uuid uuid parameters, :constraints nil)))
(api/defendpoint GET "/card/:uuid/query/csv"
"Fetch a publically-accessible Card and return query results as CSV. Does not require auth credentials. Public sharing must be enabled."
[uuid parameters]
{parameters (s/maybe su/JSONString)}
(dataset-api/as-csv (run-query-for-card-with-public-uuid uuid parameters, :constraints nil)))
(api/defendpoint GET "/card/:uuid/query/:export-format"
"Fetch a publically-accessible Card and return query results in the specified format. Does not require auth credentials. Public sharing must be enabled."
[uuid export-format parameters]
{parameters (s/maybe su/JSONString)
export-format dataset-api/export-format-schema}
(dataset-api/as-format export-format
(run-query-for-card-with-public-uuid uuid parameters, :constraints nil)))
;;; ------------------------------------------------------------ Public Dashboards ------------------------------------------------------------
......
......@@ -24,7 +24,8 @@
:public-dashboard
:public-question
:pulse
:question))
:question
:xlsx-download))
(defn- pre-insert [{context :context, :as query-execution}]
(u/prog1 query-execution
......
......@@ -218,6 +218,7 @@
For the purposes of tracking we record each call to this function as a QueryExecution in the database.
OPTIONS must conform to the `DatasetQueryOptions` schema; refer to that for more details."
{:style/indent 1}
[query, options :- DatasetQueryOptions]
(run-and-save-query! (assoc query :info (assoc options
:query-hash (qputil/query-hash query)
......
......@@ -34,13 +34,15 @@
(def ^:private embed (partial entrypoint "embed" :embeddable))
(defroutes ^:private public-routes
(GET ["/question/:uuid.csv" :uuid u/uuid-regex] [uuid] (resp/redirect (format "/api/public/card/%s/query/csv" uuid)))
(GET ["/question/:uuid.json" :uuid u/uuid-regex] [uuid] (resp/redirect (format "/api/public/card/%s/query/json" uuid)))
(GET ["/question/:uuid.:export-format" :uuid u/uuid-regex]
[uuid export-format]
(resp/redirect (format "/api/public/card/%s/query/%s" uuid export-format)))
(GET "*" [] public))
(defroutes ^:private embed-routes
(GET "/question/:token.csv" [token] (resp/redirect (format "/api/embed/card/%s/query/csv" token)))
(GET "/question/:token.json" [token] (resp/redirect (format "/api/embed/card/%s/query/json" token)))
(GET "/question/:token.:export-format"
[token export-format]
(resp/redirect (format "/api/embed/card/%s/query/%s" token export-format)))
(GET "*" [] embed))
;; Redirect naughty users who try to visit a page other than setup if setup is not yet complete
......
(ns metabase.api.card-test
"Tests for /api/card endpoints."
(:require [cheshire.core :as json]
[dk.ative.docjure.spreadsheet :as spreadsheet]
[expectations :refer :all]
[medley.core :as m]
[metabase
......@@ -24,7 +25,8 @@
[metabase.test.data.users :refer :all]
[toucan.db :as db]
[toucan.util.test :as tt])
(:import java.util.UUID))
(:import java.io.ByteArrayInputStream
java.util.UUID))
;;; CARD LIFECYCLE
......@@ -400,20 +402,31 @@
(perms/grant-native-read-permissions! (perms-group/all-users) database-id)
((user->client :rasta) :post 200 (format "card/%d/query/json" (u/get-id card))))))
;;; Tests for GET /api/card/:id/xlsx
(expect
[{:col "COUNT(*)"} {:col 75.0}]
(do-with-temp-native-card
(fn [database-id card]
(perms/grant-native-read-permissions! (perms-group/all-users) database-id)
(->> ((user->client :rasta) :post 200 (format "card/%d/query/xlsx" (u/get-id card)) {:request-options {:as :byte-array}})
ByteArrayInputStream.
spreadsheet/load-workbook
(spreadsheet/select-sheet "Query result")
(spreadsheet/select-columns {:A :col})))))
;;; Test GET /api/card/:id/query/csv & GET /api/card/:id/json **WITH PARAMETERS**
;;; Test GET /api/card/:id/query/csv & GET /api/card/:id/json & GET /api/card/:id/query/xlsx **WITH PARAMETERS**
(defn- do-with-temp-native-card-with-params {:style/indent 0} [f]
(tt/with-temp* [Database [{database-id :id} {:details (:details (Database (id))), :engine :h2}]
Table [{table-id :id} {:db_id database-id, :name "VENUES"}]
Card [card {:dataset_query {:database database-id
:type :native
:native {:query "SELECT COUNT(*) FROM VENUES WHERE CATEGORY_ID = {{category}};"
:template_tags {:category {:id "a9001580-3bcc-b827-ce26-1dbc82429163"
:name "category"
:display_name "Category"
:type "number"
:required true}}}}}]]
Table [{table-id :id} {:db_id database-id, :name "VENUES"}]
Card [card {:dataset_query {:database database-id
:type :native
:native {:query "SELECT COUNT(*) FROM VENUES WHERE CATEGORY_ID = {{category}};"
:template_tags {:category {:id "a9001580-3bcc-b827-ce26-1dbc82429163"
:name "category"
:display_name "Category"
:type "number"
:required true}}}}}]]
(f database-id card)))
(def ^:private ^:const ^String encoded-params
......@@ -436,6 +449,17 @@
(fn [database-id card]
((user->client :rasta) :post 200 (format "card/%d/query/json?parameters=%s" (u/get-id card) encoded-params)))))
;; XLSX
(expect
[{:col "COUNT(*)"} {:col 8.0}]
(do-with-temp-native-card-with-params
(fn [database-id card]
(->> ((user->client :rasta) :post 200 (format "card/%d/query/xlsx?parameters=%s" (u/get-id card) encoded-params)
{:request-options {:as :byte-array}})
ByteArrayInputStream.
spreadsheet/load-workbook
(spreadsheet/select-sheet "Query result")
(spreadsheet/select-columns {:A :col})))))
;;; +------------------------------------------------------------------------------------------------------------------------+
;;; | COLLECTIONS |
......
(ns metabase.api.embed-test
(:require [buddy.sign.jwt :as jwt]
[crypto.random :as crypto-random]
[dk.ative.docjure.spreadsheet :as spreadsheet]
[expectations :refer :all]
[metabase
[http-client :as http]
......@@ -13,7 +14,8 @@
[metabase.test
[data :as data]
[util :as tu]]
[toucan.util.test :as tt]))
[toucan.util.test :as tt])
(:import java.io.ByteArrayInputStream))
(defn random-embedding-secret-key [] (crypto-random/hex 32))
......@@ -63,7 +65,13 @@
(case results-format
"" (successful-query-results)
"/json" [{:count 100}]
"/csv" "count\n100\n")))
"/csv" "count\n100\n"
"/xlsx" (fn [body]
(->> (ByteArrayInputStream. body)
spreadsheet/load-workbook
(spreadsheet/select-sheet "Query result")
(spreadsheet/select-columns {:A :col})
(= [{:col "count"} {:col 100.0}]))))))
(defn dissoc-id-and-name {:style/indent 0} [obj]
(dissoc obj :id :name))
......@@ -130,7 +138,7 @@
(:parameters (http/client :get 200 (card-url card {:params {:c 100}}))))))
;; ------------------------------------------------------------ GET /api/embed/card/:token/query (and JSON and CSV variants) ------------------------------------------------------------
;; ------------------------------------------------------------ GET /api/embed/card/:token/query (and JSON/CSV/XLSX variants) ------------------------------------------------------------
(defn- card-query-url [card response-format & [additional-token-params]]
(str "embed/card/"
......@@ -138,21 +146,23 @@
"/query"
response-format))
(defmacro ^:private expect-for-response-formats {:style/indent 1} [[response-format-binding] expected actual]
(defmacro ^:private expect-for-response-formats {:style/indent 1} [[response-format-binding request-options-binding] expected actual]
`(do
~@(for [response-format ["" "/json" "/csv"]]
~@(for [[response-format request-options] [[""] ["/json"] ["/csv"] ["/xlsx" {:as :byte-array}]]]
`(expect
(let [~response-format-binding ~response-format]
(let [~response-format-binding ~response-format
~(or request-options-binding '_) {:request-options ~request-options}]
~expected)
(let [~response-format-binding ~response-format]
(let [~response-format-binding ~response-format
~(or request-options-binding '_) {:request-options ~request-options}]
~actual)))))
;; it should be possible to run a Card successfully if you jump through the right hoops...
(expect-for-response-formats [response-format]
(expect-for-response-formats [response-format request-options]
(successful-query-results response-format)
(with-embedding-enabled-and-new-secret-key
(with-temp-card [card {:enable_embedding true}]
(http/client :get 200 (card-query-url card response-format)))))
(http/client :get 200 (card-query-url card response-format) request-options))))
;; but if the card has an invalid query we should just get a generic "query failed" exception (rather than leaking query info)
(expect-for-response-formats [response-format]
......@@ -193,11 +203,11 @@
(http/client :get 400 (card-query-url card response-format)))))
;; if `:locked` param is present, request should succeed
(expect-for-response-formats [response-format]
(expect-for-response-formats [response-format request-options]
(successful-query-results response-format)
(with-embedding-enabled-and-new-secret-key
(with-temp-card [card {:enable_embedding true, :embedding_params {:abc "locked"}}]
(http/client :get 200 (card-query-url card response-format {:params {:abc 100}})))))
(http/client :get 200 (card-query-url card response-format {:params {:abc 100}}) request-options))))
;; If `:locked` parameter is present in URL params, request should fail
(expect-for-response-formats [response-format]
......@@ -232,18 +242,18 @@
(http/client :get 400 (str (card-query-url card response-format {:params {:abc 100}}) "?abc=200")))))
;; If an `:enabled` param is present in the JWT, that's ok
(expect-for-response-formats [response-format]
(expect-for-response-formats [response-format request-options]
(successful-query-results response-format)
(with-embedding-enabled-and-new-secret-key
(with-temp-card [card {:enable_embedding true, :embedding_params {:abc "enabled"}}]
(http/client :get 200 (card-query-url card response-format {:params {:abc "enabled"}})))))
(http/client :get 200 (card-query-url card response-format {:params {:abc "enabled"}}) request-options))))
;; If an `:enabled` param is present in URL params but *not* the JWT, that's ok
(expect-for-response-formats [response-format]
(expect-for-response-formats [response-format request-options]
(successful-query-results response-format)
(with-embedding-enabled-and-new-secret-key
(with-temp-card [card {:enable_embedding true, :embedding_params {:abc "enabled"}}]
(http/client :get 200 (str (card-query-url card response-format) "?abc=200")))))
(http/client :get 200 (str (card-query-url card response-format) "?abc=200") request-options))))
;; ------------------------------------------------------------ GET /api/embed/dashboard/:token ------------------------------------------------------------
......
(ns metabase.api.public-test
"Tests for `api/public/` (public links) endpoints."
(:require [cheshire.core :as json]
[dk.ative.docjure.spreadsheet :as spreadsheet]
[expectations :refer :all]
[metabase
[http-client :as http]
......@@ -18,7 +19,8 @@
[metabase.test.data.users :as test-users]
[toucan.db :as db]
[toucan.util.test :as tt])
(:import java.util.UUID))
(:import java.io.ByteArrayInputStream
java.util.UUID))
;;; ------------------------------------------------------------ Helper Fns ------------------------------------------------------------
......@@ -109,7 +111,7 @@
(update-in [(data/id :categories :name) :values] count))))
;;; ------------------------------------------------------------ GET /api/public/card/:uuid/query (and JSON and CSV versions) ------------------------------------------------------------
;;; ------------------------------------------------------------ GET /api/public/card/:uuid/query (and JSON/CSV/XSLX versions) ------------------------------------------------------------
;; Check that we *cannot* execute a PublicCard if the setting is disabled
(expect
......@@ -152,6 +154,17 @@
(with-temp-public-card [{uuid :public_uuid}]
(http/client :get 200 (str "public/card/" uuid "/query/csv"), :format :csv))))
;; Check that we can exec a PublicCard and get results as XLSX
(expect
[{:col "count"} {:col 100.0}]
(tu/with-temporary-setting-values [enable-public-sharing true]
(with-temp-public-card [{uuid :public_uuid}]
(->> (http/client :get 200 (str "public/card/" uuid "/query/xlsx") {:request-options {:as :byte-array}})
ByteArrayInputStream.
spreadsheet/load-workbook
(spreadsheet/select-sheet "Query result")
(spreadsheet/select-columns {:A :col})))))
;; Check that we can exec a PublicCard with `?parameters`
(expect
[{:type "category", :value 2}]
......
......@@ -49,11 +49,13 @@
(defn- parse-response
"Deserialize the JSON response or return as-is if that fails."
[body]
(try
(auto-deserialize-dates (json/parse-string body keyword))
(catch Throwable _
(when-not (s/blank? body)
body))))
(if-not (string? body)
body
(try
(auto-deserialize-dates (json/parse-string body keyword))
(catch Throwable _
(when-not (s/blank? body)
body)))))
;;; authentication
......@@ -104,7 +106,7 @@
:put client/put
:delete client/delete))
(defn- -client [credentials method expected-status url http-body url-param-kwargs]
(defn- -client [credentials method expected-status url http-body url-param-kwargs request-options]
;; Since the params for this function can get a little complicated make sure we validate them
{:pre [(or (u/maybe? map? credentials)
(string? credentials))
......@@ -113,7 +115,7 @@
(string? url)
(u/maybe? map? http-body)
(u/maybe? map? url-param-kwargs)]}
(let [request-map (build-request-map credentials http-body)
(let [request-map (merge (build-request-map credentials http-body) request-options)
request-fn (method->request-fn method)
url (build-url url url-param-kwargs)
method-name (s/upper-case (name method))
......@@ -146,10 +148,10 @@
* URL Base URL of the request, which will be appended to `*url-prefix*`. e.g. `card/1/favorite`
* HTTP-BODY-MAP Optional map to send a the JSON-serialized HTTP body of the request
* URL-KWARGS key-value pairs that will be encoded and added to the URL as GET params"
{:arglists '([credentials? method expected-status-code? url http-body-map? & url-kwargs])}
{:arglists '([credentials? method expected-status-code? url request-options? http-body-map? & url-kwargs])}
[& args]
(let [[credentials [method & args]] (u/optional #(or (map? %)
(string? %)) args)
(let [[credentials [method & args]] (u/optional #(or (map? %) (string? %)) args)
[expected-status [url & args]] (u/optional integer? args)
[{:keys [request-options]} args] (u/optional #(and (map? %) (:request-options %)) args {:request-options {}})
[body [& {:as url-param-kwargs}]] (u/optional map? args)]
(-client credentials method expected-status url body url-param-kwargs)))
(-client credentials method expected-status url body url-param-kwargs request-options)))
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment