;; This Source Code Form is subject to the terms of the Mozilla Public ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; ;; Copyright (c) KALEIDOS INC (ns app.util.http "A http client with rx streams interface." (:require [app.common.data :as d] [app.common.transit :as t] [app.common.uri :as u] [app.config :as cfg] [app.util.cache :as c] [app.util.globals :as globals] [app.util.perf :as perf] [app.util.webapi :as wapi] [beicon.v2.core :as rx] [cuerdas.core :as str] [promesa.core :as p])) (defprotocol IBodyData "A helper for define body data with the appropriate headers." (-update-headers [_ headers]) (-get-body-data [_])) (extend-protocol IBodyData globals/FormData (-get-body-data [it] it) (-update-headers [_ headers] (dissoc headers "content-type" "Content-Type")) default (-get-body-data [it] it) (-update-headers [_ headers] headers)) (defn translate-method [method] (case method :head "HEAD" :options "OPTIONS" :get "GET" :post "POST" :put "PUT" :patch "PATCH" :delete "DELETE" :trace "TRACE")) (defn parse-headers [headers] (into {} (map vec) (seq (.entries ^js headers)))) (defn default-headers [] {"x-frontend-version" (:full cfg/version) "x-client" (str "penpot-frontend/" (:full cfg/version))}) ;; Storage to save the average time of the requests (defonce network-averages (atom {})) (defn fetch [{:keys [method uri query headers body mode omit-default-headers credentials] :or {mode :cors headers {} credentials "same-origin"}}] (rx/create (fn [subscriber] (let [controller (js/AbortController.) signal (.-signal ^js controller) unsubscribed? (volatile! false) abortable? (volatile! true) query (cond (string? query) query (map? query) (u/map->query-string query) :else nil) uri (cond-> uri (string? uri) (u/uri) (some? query) (assoc :query query)) headers (cond-> headers (not omit-default-headers) (merge (default-headers))) headers (-update-headers body headers) body (-get-body-data body) params #js {:method (translate-method method) :headers (clj->js headers) :body body :mode (d/name mode) :redirect "follow" :credentials credentials :referrerPolicy "no-referrer" :signal signal} start (perf/timestamp)] (-> (js/fetch (str uri) params) (p/then (fn [response] (vreset! abortable? false) (.next ^js subscriber response) (.complete ^js subscriber))) (p/catch (fn [cause] (vreset! abortable? false) (when-not (or @unsubscribed? (= (.-name ^js cause) "AbortError")) (let [error (ex-info (ex-message cause) {:type :internal :code :fetch-error :hint "unable to perform fetch operation" :uri uri :headers headers} cause)] (.error ^js subscriber error))))) (p/finally (fn [] (let [{:keys [count average] :or {count 0 average 0}} (get @network-averages (:path uri)) current-time (- (perf/timestamp) start) average (+ (* average (/ count (inc count))) (/ current-time (inc count))) count (inc count)] (swap! network-averages assoc (:path uri) {:count count :average average}))))) (fn [] (vreset! unsubscribed? true) (when @abortable? ;; Do NOT pass a custom reason to .abort(): browsers that support ;; AbortController reason (Chrome 98+, Firefox 97+) would reject ;; the fetch promise with the supplied value directly. When that ;; value is a ClojureScript ExceptionInfo its `.name` property is ;; "Error", not "AbortError", which defeats every existing guard ;; that checks `(= (.-name cause) "AbortError")`. Calling .abort ;; without a reason always produces a native DOMException whose ;; `.name` is "AbortError", which is correctly recognised and ;; suppressed by both the p/catch handler and the global ;; unhandled-exception filter. (.abort ^js controller))))))) (defn response->map [response] {:status (.-status ^js response) :uri (.-url ^js response) :headers (parse-headers (.-headers ^js response)) :body (.-body ^js response) ::response response}) (defn process-response-type [response-type response] (let [native-response (::response response) body (case response-type :buffer (.arrayBuffer ^js native-response) :json (.json ^js native-response) :text (.text ^js native-response) :blob (.blob ^js native-response))] (->> (rx/from body) (rx/map (fn [body] (assoc response :body body)))))) (defn send! [{:keys [response-type] :or {response-type :text} :as params}] (->> (fetch params) (rx/map response->map) (rx/mapcat (partial process-response-type response-type)))) (defn form-data [data] (letfn [(append [form k v] (if (list? v) (.append form (name k) (first v) (second v)) (.append form (name k) v)) form)] (reduce-kv append (js/FormData.) data))) (defn transit-data [data] (reify IBodyData (-get-body-data [_] (t/encode-str data {:type :json-verbose})) (-update-headers [_ headers] (assoc headers "content-type" "application/transit+json")))) (defn conditional-decode-transit [{:keys [body headers] :as response}] (let [contenttype (get headers "content-type")] (if (and (str/starts-with? contenttype "application/transit+json") (string? body) (pos? (count body))) (assoc response :body (t/decode-str body)) response))) (defn conditional-error-decode-transit [{:keys [body status] :as response}] (if (and (>= status 400) (string? body)) (assoc response :body (t/decode-str body)) response)) (defn success? [{:keys [status]}] (<= 200 status 299)) (defn server-error? [{:keys [status]}] (<= 500 status 599)) (defn client-error? [{:keys [status]}] (<= 400 status 499)) (defn as-promise [observable] (p/create (fn [resolve reject] (->> (rx/take 1 observable) (rx/subs! resolve reject))))) (defn fetch-data-uri ([uri] (fetch-data-uri uri false)) ([uri throw-err?] (let [request-str (->> (send! {:method :get :uri uri :response-type :blob :omit-default-headers true}) (rx/tap (fn [resp] (when (or (< (:status resp) 200) (>= (:status resp) 300)) (throw (js/Error. "Error fetching data uri" #js {:cause (clj->js resp)}))))) (rx/map :body) (rx/mapcat wapi/read-file-as-data-url) (rx/map #(hash-map uri %)) (c/with-cache {:key uri :max-age (* 1000 60 60 4)}))] ;; We need to check `throw-err?` after the cache is resolved otherwise we cannot cache request ;; with different values of throw-err. By default we throw always the exception and then we just ;; ignore when `throw-err?` is `true` (if (not throw-err?) (->> request-str (rx/catch #(rx/empty))) request-str)))) (defn fetch-text [url] (->> (send! {:method :get :mode :cors :omit-default-headers true :uri url :response-type :text}) (rx/map :body) (c/with-cache {:key url :max-age (* 1000 60 60 4)})))