Move HTML rendering to btcprice, send raw JSON over both WebSockets
Price and Strike WebSockets now emit JSON instead of HTML fragments. Added Strike WebSocket endpoint and wired strike/ticker into API routes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -47,7 +47,8 @@
|
|||||||
{:base-path "/api"
|
{:base-path "/api"
|
||||||
:query-fn #ig/ref :db.sql/query-fn
|
:query-fn #ig/ref :db.sql/query-fn
|
||||||
:binance #ig/ref :ws/binance
|
:binance #ig/ref :ws/binance
|
||||||
:frankfurter #ig/ref :frankfurter/rates}
|
:frankfurter #ig/ref :frankfurter/rates
|
||||||
|
:strike #ig/ref :strike/ticker}
|
||||||
|
|
||||||
:ws/binance
|
:ws/binance
|
||||||
{:query-fn #ig/ref :db.sql/query-fn
|
{:query-fn #ig/ref :db.sql/query-fn
|
||||||
|
|||||||
@@ -37,38 +37,49 @@
|
|||||||
(str sourceCurrency "/" targetCurrency " " amount)))
|
(str sourceCurrency "/" targetCurrency " " amount)))
|
||||||
(str/join " ")))
|
(str/join " ")))
|
||||||
|
|
||||||
|
(defn- rates->map
|
||||||
|
"Index the rates array by sourceCurrency/targetCurrency pair."
|
||||||
|
[rates]
|
||||||
|
(into {}
|
||||||
|
(map (fn [{:keys [sourceCurrency targetCurrency amount]}]
|
||||||
|
[(str sourceCurrency "/" targetCurrency) amount])
|
||||||
|
rates)))
|
||||||
|
|
||||||
(defn- poll!
|
(defn- poll!
|
||||||
"Fetch rates and insert into the database."
|
"Fetch rates and insert into the database. Updates latest-atom."
|
||||||
[client api-key query-fn]
|
[client api-key query-fn latest-atom]
|
||||||
(let [body (fetch-rates client api-key)
|
(let [body (fetch-rates client api-key)
|
||||||
rates (json/read-str body :key-fn keyword)]
|
rates (json/read-str body :key-fn keyword)]
|
||||||
(query-fn :insert-strike-price! {:rates (->jsonb body)})
|
(query-fn :insert-strike-price! {:rates (->jsonb body)})
|
||||||
|
(reset! latest-atom (rates->map rates))
|
||||||
(log/info "Strike:" (format-rates rates))))
|
(log/info "Strike:" (format-rates rates))))
|
||||||
|
|
||||||
(defn- start-poll-loop!
|
(defn- start-poll-loop!
|
||||||
"Fetch immediately, then poll every 10 seconds."
|
"Fetch immediately, then poll every 10 seconds."
|
||||||
[client api-key query-fn running?]
|
[client api-key query-fn running? latest-atom]
|
||||||
(future
|
(future
|
||||||
(try
|
(try
|
||||||
(poll! client api-key query-fn)
|
(poll! client api-key query-fn latest-atom)
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
(log/error e "Strike initial fetch failed")))
|
(log/error e "Strike initial fetch failed")))
|
||||||
(while @running?
|
(while @running?
|
||||||
(Thread/sleep 10000)
|
(Thread/sleep 10000)
|
||||||
(when @running?
|
(when @running?
|
||||||
(try
|
(try
|
||||||
(poll! client api-key query-fn)
|
(poll! client api-key query-fn latest-atom)
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
(log/error e "Strike poll failed")))))))
|
(log/error e "Strike poll failed")))))))
|
||||||
|
|
||||||
(defmethod ig/init-key :strike/ticker
|
(defmethod ig/init-key :strike/ticker
|
||||||
[_ {:keys [query-fn api-key]}]
|
[_ {:keys [query-fn api-key]}]
|
||||||
(log/info "Starting Strike ticker poller")
|
(log/info "Starting Strike ticker poller")
|
||||||
(let [client (HttpClient/newHttpClient)
|
(let [client (HttpClient/newHttpClient)
|
||||||
running? (atom true)
|
running? (atom true)
|
||||||
fut (start-poll-loop! client api-key query-fn running?)]
|
latest-rates (atom nil)
|
||||||
{:running? running?
|
fut (start-poll-loop! client api-key query-fn running? latest-rates)]
|
||||||
:future fut}))
|
{:running? running?
|
||||||
|
:future fut
|
||||||
|
:latest-rates latest-rates}))
|
||||||
|
|
||||||
(defmethod ig/halt-key! :strike/ticker
|
(defmethod ig/halt-key! :strike/ticker
|
||||||
[_ {:keys [running? future]}]
|
[_ {:keys [running? future]}]
|
||||||
|
|||||||
@@ -12,9 +12,6 @@
|
|||||||
[reitit.swagger :as swagger])
|
[reitit.swagger :as swagger])
|
||||||
(:import
|
(:import
|
||||||
[io.undertow.websockets.core WebSockets WebSocketChannel]
|
[io.undertow.websockets.core WebSockets WebSocketChannel]
|
||||||
[java.time Instant ZoneId]
|
|
||||||
[java.time.format DateTimeFormatter]
|
|
||||||
[java.util Locale]
|
|
||||||
[java.util.concurrent LinkedBlockingQueue TimeUnit]))
|
[java.util.concurrent LinkedBlockingQueue TimeUnit]))
|
||||||
|
|
||||||
(defn- price->json [{:keys [price prev-price recorded-at]} rates]
|
(defn- price->json [{:keys [price prev-price recorded-at]} rates]
|
||||||
@@ -25,41 +22,6 @@
|
|||||||
rates (assoc :price_eur (str (.multiply (bigdec price) (:eur rates)))
|
rates (assoc :price_eur (str (.multiply (bigdec price) (:eur rates)))
|
||||||
:price_dkk (str (.multiply (bigdec price) (:dkk rates)))))))
|
:price_dkk (str (.multiply (bigdec price) (:dkk rates)))))))
|
||||||
|
|
||||||
(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->html [{:keys [price prev-price recorded-at]} rates]
|
|
||||||
(let [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/from 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 rates
|
|
||||||
(let [eur (double (:eur rates))
|
|
||||||
dkk (double (:dkk rates))
|
|
||||||
p-eur (double (.multiply (bigdec price) (:eur rates)))
|
|
||||||
p-dkk (double (.multiply (bigdec price) (:dkk rates)))]
|
|
||||||
(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>")))
|
|
||||||
|
|
||||||
(defn- start-ws-send-loop! [^WebSocketChannel channel price-atom rates-atom]
|
(defn- start-ws-send-loop! [^WebSocketChannel channel price-atom rates-atom]
|
||||||
(let [queue (LinkedBlockingQueue.)
|
(let [queue (LinkedBlockingQueue.)
|
||||||
wkey (keyword (gensym "ws-"))
|
wkey (keyword (gensym "ws-"))
|
||||||
@@ -76,7 +38,7 @@
|
|||||||
(when (.isOpen channel)
|
(when (.isOpen channel)
|
||||||
(if (.poll queue 15 TimeUnit/SECONDS)
|
(if (.poll queue 15 TimeUnit/SECONDS)
|
||||||
(when-let [v @price-atom]
|
(when-let [v @price-atom]
|
||||||
(WebSockets/sendTextBlocking (price->html v @rates-atom) channel))
|
(WebSockets/sendTextBlocking (price->json v @rates-atom) channel))
|
||||||
(WebSockets/sendTextBlocking "" channel))
|
(WebSockets/sendTextBlocking "" channel))
|
||||||
(recur)))
|
(recur)))
|
||||||
(catch Exception _)
|
(catch Exception _)
|
||||||
@@ -93,6 +55,34 @@
|
|||||||
{:on-open (fn [{:keys [^WebSocketChannel channel]}]
|
{:on-open (fn [{:keys [^WebSocketChannel channel]}]
|
||||||
(start-ws-send-loop! channel price-atom rates-atom))}}))
|
(start-ws-send-loop! channel price-atom rates-atom))}}))
|
||||||
|
|
||||||
|
(defn- start-strike-ws-loop! [^WebSocketChannel channel strike-atom]
|
||||||
|
(let [queue (LinkedBlockingQueue.)
|
||||||
|
wkey (keyword (gensym "ws-s-"))]
|
||||||
|
(add-watch strike-atom wkey
|
||||||
|
(fn [_ _ _ _] (.offer queue :update)))
|
||||||
|
(when @strike-atom
|
||||||
|
(.offer queue :update))
|
||||||
|
(future
|
||||||
|
(try
|
||||||
|
(loop []
|
||||||
|
(when (.isOpen channel)
|
||||||
|
(if (.poll queue 15 TimeUnit/SECONDS)
|
||||||
|
(when-let [rates @strike-atom]
|
||||||
|
(WebSockets/sendTextBlocking (json/write-str rates) channel))
|
||||||
|
(WebSockets/sendTextBlocking "" channel))
|
||||||
|
(recur)))
|
||||||
|
(catch Exception _)
|
||||||
|
(finally
|
||||||
|
(remove-watch strike-atom wkey)
|
||||||
|
(when (.isOpen channel)
|
||||||
|
(try (.close channel) (catch Exception _))))))))
|
||||||
|
|
||||||
|
(defn- ws-strike-handler [{:keys [strike]} _req]
|
||||||
|
(let [strike-atom (:latest-rates strike)]
|
||||||
|
{:undertow/websocket
|
||||||
|
{:on-open (fn [{:keys [^WebSocketChannel channel]}]
|
||||||
|
(start-strike-ws-loop! channel strike-atom))}}))
|
||||||
|
|
||||||
(defn- latest-price-handler [{:keys [binance frankfurter]} _req]
|
(defn- latest-price-handler [{:keys [binance frankfurter]} _req]
|
||||||
(let [price-atom (:latest-price binance)
|
(let [price-atom (:latest-price binance)
|
||||||
rates-atom (:rates frankfurter)
|
rates-atom (:rates frankfurter)
|
||||||
@@ -117,7 +107,11 @@
|
|||||||
:no-doc true
|
:no-doc true
|
||||||
:middleware []}]
|
:middleware []}]
|
||||||
["/price/latest"
|
["/price/latest"
|
||||||
{:get (fn [req] (latest-price-handler opts req))}]])
|
{:get (fn [req] (latest-price-handler opts req))}]
|
||||||
|
["/strike/ws"
|
||||||
|
{:get (fn [req] (ws-strike-handler opts req))
|
||||||
|
:no-doc true
|
||||||
|
:middleware []}]])
|
||||||
|
|
||||||
(defn route-data [opts]
|
(defn route-data [opts]
|
||||||
(merge
|
(merge
|
||||||
|
|||||||
Reference in New Issue
Block a user