Render price and strike HTML in btcprice from btcdata JSON WebSockets
Receive raw JSON from btcdata, transform to HTML in ws_proxy.clj. Added data.json dependency, price and strike rendering, safelist updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,8 @@
|
||||
|
||||
;; Serialization
|
||||
metosin/muuntaja {:mvn/version "0.6.11"}
|
||||
luminus-transit/luminus-transit {:mvn/version "0.1.6"}}
|
||||
luminus-transit/luminus-transit {:mvn/version "0.1.6"}
|
||||
org.clojure/data.json {:mvn/version "2.5.1"}}
|
||||
|
||||
:aliases
|
||||
{:build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.9"}}
|
||||
|
||||
@@ -18,8 +18,13 @@
|
||||
[:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6
|
||||
[:p.text-center.text-gray-400.text-sm "Connecting..."]]]]
|
||||
|
||||
[:div.mt-4 {:hx-ext "ws" :ws-connect "/ws/strike"}
|
||||
[:div#strike-display
|
||||
[:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6
|
||||
[:p.text-center.text-gray-400.text-sm "Loading Strike rates..."]]]]
|
||||
|
||||
;; Safelist classes used in btcdata HTML fragments
|
||||
[:div {:class "hidden text-4xl text-2xl font-bold text-green-600 text-red-600 text-gray-900 text-xs text-gray-400 mt-2 flex justify-center gap-4 mt-1 mt-3 text-sm text-gray-600"
|
||||
[:div {:class "hidden text-4xl text-2xl font-bold text-green-600 text-red-600 text-gray-900 text-xs text-gray-400 mt-2 flex justify-center justify-between items-center gap-4 mt-1 mt-3 text-sm text-gray-600 text-gray-500 mb-3 py-1 grid grid-cols-2 gap-x-6 gap-y-1"
|
||||
:aria-hidden "true"}]))
|
||||
|
||||
(defn- ui-routes [opts]
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
(ns pmagnus.btcprice.web.routes.ws-proxy
|
||||
(:require
|
||||
[clojure.data.json :as json]
|
||||
[clojure.tools.logging :as log]
|
||||
[integrant.core :as ig])
|
||||
(:import
|
||||
[io.undertow.websockets.core WebSockets WebSocketChannel]
|
||||
[java.net URI]
|
||||
[java.net.http HttpClient WebSocket$Builder WebSocket$Listener]
|
||||
[java.time Instant ZoneId]
|
||||
[java.time.format DateTimeFormatter]
|
||||
[java.util Locale]
|
||||
[java.util.concurrent CompletableFuture]))
|
||||
|
||||
(defn- close-quietly [^WebSocketChannel ch]
|
||||
@@ -17,9 +21,9 @@
|
||||
(try (.sendClose ws java.net.http.WebSocket/NORMAL_CLOSURE "") (catch Exception _))))
|
||||
|
||||
(defn- connect-upstream
|
||||
"Open a Java HttpClient WebSocket to btcdata and relay text frames to the
|
||||
browser channel. Returns the upstream WebSocket."
|
||||
[^String btcdata-ws-url ^WebSocketChannel browser-ch]
|
||||
"Open a Java HttpClient WebSocket to btcdata, transform each text frame
|
||||
with xf, and send the result to the browser channel."
|
||||
[^String btcdata-ws-url ^WebSocketChannel browser-ch xf]
|
||||
(let [client (HttpClient/newHttpClient)
|
||||
listener (reify java.net.http.WebSocket$Listener
|
||||
(onOpen [_ ws]
|
||||
@@ -27,8 +31,8 @@
|
||||
(onText [_ ws data last?]
|
||||
(let [text (str data)]
|
||||
(try
|
||||
(when (.isOpen browser-ch)
|
||||
(WebSockets/sendTextBlocking text browser-ch))
|
||||
(when (and (.isOpen browser-ch) (seq text))
|
||||
(WebSockets/sendTextBlocking (xf text) browser-ch))
|
||||
(catch Exception e
|
||||
(log/debug e "Error forwarding to browser")
|
||||
(close-upstream-quietly ws))))
|
||||
@@ -45,25 +49,89 @@
|
||||
(.buildAsync (URI. btcdata-ws-url) listener)
|
||||
(.join))))
|
||||
|
||||
(defn- ws-proxy-handler [{:keys [btcdata-url]} _req]
|
||||
(let [btcdata-ws-url (str (.replaceFirst ^String btcdata-url "^http" "ws")
|
||||
"/api/price/ws")
|
||||
upstream-atom (atom nil)]
|
||||
(defn- ws-handler [btcdata-ws-url xf _req]
|
||||
(let [upstream-atom (atom nil)]
|
||||
{:undertow/websocket
|
||||
{:on-open
|
||||
(fn [{:keys [^WebSocketChannel channel]}]
|
||||
(log/info "Browser connected, proxying to" btcdata-ws-url)
|
||||
(reset! upstream-atom (connect-upstream btcdata-ws-url channel)))
|
||||
(reset! upstream-atom (connect-upstream btcdata-ws-url channel xf)))
|
||||
:on-close-message
|
||||
(fn [_]
|
||||
(log/debug "Browser closed")
|
||||
(close-upstream-quietly @upstream-atom))}}))
|
||||
|
||||
(defn- ws-proxy-routes [opts]
|
||||
[["/ws/price"
|
||||
{:get (fn [req] (ws-proxy-handler opts req))
|
||||
:no-doc true
|
||||
:middleware []}]])
|
||||
;; --- Price rendering ---
|
||||
|
||||
(def ^:private ts-fmt (DateTimeFormatter/ofPattern "dd-MM HH:mm:ss"))
|
||||
(def ^:private da-locale (Locale. "da" "DK"))
|
||||
|
||||
(defn- da-fmt [fmt val]
|
||||
(String/format da-locale fmt (into-array Object [val])))
|
||||
|
||||
(defn- price-json->html [text]
|
||||
(let [{:strs [price prev_price recorded_at
|
||||
price_eur price_dkk]} (json/read-str text)
|
||||
p (double (bigdec price))
|
||||
pp (when prev_price (double (bigdec prev_price)))
|
||||
color (cond
|
||||
(nil? pp) "text-gray-900"
|
||||
(> p pp) "text-green-600"
|
||||
(< p pp) "text-red-600"
|
||||
:else "text-gray-900")
|
||||
ts (.format (.atZone (Instant/parse recorded_at) (ZoneId/systemDefault)) ts-fmt)]
|
||||
(str "<div id=\"price-display\">"
|
||||
"<div class=\"bg-white rounded-2xl shadow-sm border border-gray-200 p-6\">"
|
||||
"<div class=\"text-center\">"
|
||||
"<p class=\"text-4xl font-bold " color "\">$" (da-fmt "%,.2f" p) "</p>"
|
||||
(when price_eur
|
||||
(let [p-eur (double (bigdec price_eur))
|
||||
p-dkk (double (bigdec price_dkk))
|
||||
eur (/ p-eur p)
|
||||
dkk (/ p-dkk p)]
|
||||
(str "<div class=\"flex justify-center gap-4 mt-3\">"
|
||||
"<span class=\"text-2xl font-bold text-gray-600\">\u20AC" (da-fmt "%,.0f" p-eur) "</span>"
|
||||
"<span class=\"text-2xl font-bold text-gray-600\">" (da-fmt "%,.0f" p-dkk) " kr</span>"
|
||||
"</div>"
|
||||
"<div class=\"flex justify-center gap-4 mt-1\">"
|
||||
"<span class=\"text-xs text-gray-400\">EUR/USD " (da-fmt "%.4f" eur) "</span>"
|
||||
"<span class=\"text-xs text-gray-400\">DKK/USD " (da-fmt "%.4f" dkk) "</span>"
|
||||
"</div>")))
|
||||
"<p class=\"text-xs text-gray-400 mt-2\">Updated " ts "</p>"
|
||||
"</div></div></div>")))
|
||||
|
||||
;; --- Strike rendering ---
|
||||
|
||||
(def ^:private strike-pairs ["BTC/USDT" "BTC/EUR" "USDT/EUR" "EUR/BTC"])
|
||||
|
||||
(defn- strike-json->html [text]
|
||||
(let [rates (json/read-str text)]
|
||||
(str "<div id=\"strike-display\">"
|
||||
"<div class=\"bg-white rounded-2xl shadow-sm border border-gray-200 p-6\">"
|
||||
"<p class=\"text-center text-sm font-bold text-gray-500 mb-3\">Strike Ticker</p>"
|
||||
"<div class=\"grid grid-cols-2 gap-x-6 gap-y-1\">"
|
||||
(apply str
|
||||
(for [pair strike-pairs
|
||||
:let [amount (get rates pair)]]
|
||||
(str "<div class=\"flex justify-between items-center py-1\">"
|
||||
"<span class=\"text-sm text-gray-500\">" pair "</span>"
|
||||
"<span class=\"text-sm font-bold text-gray-900\">"
|
||||
(or amount "\u2014")
|
||||
"</span></div>")))
|
||||
"</div></div></div>")))
|
||||
|
||||
;; --- Routes ---
|
||||
|
||||
(defn- ws-proxy-routes [{:keys [btcdata-url]}]
|
||||
(let [ws-base (str (.replaceFirst ^String btcdata-url "^http" "ws"))]
|
||||
[["/ws/price"
|
||||
{:get (fn [req] (ws-handler (str ws-base "/api/price/ws") price-json->html req))
|
||||
:no-doc true
|
||||
:middleware []}]
|
||||
["/ws/strike"
|
||||
{:get (fn [req] (ws-handler (str ws-base "/api/strike/ws") strike-json->html req))
|
||||
:no-doc true
|
||||
:middleware []}]]))
|
||||
|
||||
(derive :reitit.routes/ws-proxy :reitit/routes)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user