Initial commit: btcdata — BTC data backend service
Split from btcprice monolith. Owns DB, migrations, Binance WebSocket, Kraken OHLC pollers, and exposes a JSON API with SSE price stream. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
(ns pmagnus.btcdata.web.routes.api
|
||||
(:require
|
||||
[clojure.data.json :as json]
|
||||
[clojure.string :as str]
|
||||
[integrant.core :as ig]
|
||||
[pmagnus.btcdata.web.controllers.health :as health]
|
||||
[pmagnus.btcdata.web.middleware.exception :as exception]
|
||||
[pmagnus.btcdata.web.middleware.formats :as formats]
|
||||
[reitit.coercion.malli :as malli]
|
||||
[reitit.ring.coercion :as coercion]
|
||||
[reitit.ring.middleware.muuntaja :as muuntaja]
|
||||
[reitit.ring.middleware.parameters :as parameters]
|
||||
[reitit.swagger :as swagger])
|
||||
(:import
|
||||
[io.undertow.server HttpServerExchange]
|
||||
[io.undertow.util HttpString]
|
||||
[java.io BufferedWriter OutputStreamWriter]
|
||||
[java.util.concurrent LinkedBlockingQueue TimeUnit]))
|
||||
|
||||
(defn- format-sse [event data]
|
||||
(str "event: " event "\n"
|
||||
(str/join "\n" (map #(str "data: " %) (str/split-lines data)))
|
||||
"\n\n"))
|
||||
|
||||
(defn- price->json [{:keys [price prev-price recorded-at]}]
|
||||
(json/write-str
|
||||
{:price (str price)
|
||||
:prev_price (when prev-price (str prev-price))
|
||||
:recorded_at (str recorded-at)}))
|
||||
|
||||
(defn- price-stream-handler [{:keys [binance]} req]
|
||||
(let [^HttpServerExchange exchange (:server-exchange req)
|
||||
price-atom (:latest-price binance)
|
||||
headers (.getResponseHeaders exchange)
|
||||
cors-origin (or (System/getenv "CORS_ORIGIN") "*")]
|
||||
(.setStatusCode exchange 200)
|
||||
(.put headers (HttpString. "Content-Type") "text/event-stream")
|
||||
(.put headers (HttpString. "Cache-Control") "no-cache")
|
||||
(.put headers (HttpString. "X-Accel-Buffering") "no")
|
||||
(.put headers (HttpString. "Access-Control-Allow-Origin") cors-origin)
|
||||
(when-not (.isBlocking exchange)
|
||||
(.startBlocking exchange))
|
||||
(let [out (.getOutputStream exchange)
|
||||
writer (BufferedWriter. (OutputStreamWriter. out "UTF-8"))
|
||||
queue (LinkedBlockingQueue.)
|
||||
wkey (keyword (gensym "sse-"))]
|
||||
(add-watch price-atom wkey
|
||||
(fn [_ _ _ v] (.offer queue v)))
|
||||
(when-let [v @price-atom]
|
||||
(.offer queue v))
|
||||
(try
|
||||
(loop []
|
||||
(if-let [v (.poll queue 15 TimeUnit/SECONDS)]
|
||||
(do (.write writer (format-sse "price-update" (price->json v)))
|
||||
(.flush writer))
|
||||
(do (.write writer ": heartbeat\n\n")
|
||||
(.flush writer)))
|
||||
(recur))
|
||||
(catch Exception _)
|
||||
(finally
|
||||
(remove-watch price-atom wkey)
|
||||
(try (.close writer) (catch Exception _)))))
|
||||
nil))
|
||||
|
||||
(defn- latest-price-handler [{:keys [binance]} _req]
|
||||
(let [price-atom (:latest-price binance)
|
||||
v @price-atom]
|
||||
(if v
|
||||
{:status 200
|
||||
:headers {"Content-Type" "application/json"}
|
||||
:body (price->json v)}
|
||||
{:status 503
|
||||
:headers {"Content-Type" "application/json"}
|
||||
:body (json/write-str {:error "No price data yet"})})))
|
||||
|
||||
(defn- api-routes [opts]
|
||||
[["/swagger.json"
|
||||
{:get {:no-doc true
|
||||
:swagger {:info {:title "btcdata API"}}
|
||||
:handler (swagger/create-swagger-handler)}}]
|
||||
["/health"
|
||||
{:get health/healthcheck!}]
|
||||
["/price/stream"
|
||||
{:get (fn [req] (price-stream-handler opts req))
|
||||
:no-doc true
|
||||
:middleware []}]
|
||||
["/price/latest"
|
||||
{:get (fn [req] (latest-price-handler opts req))}]])
|
||||
|
||||
(defn route-data [opts]
|
||||
(merge
|
||||
opts
|
||||
{:coercion malli/coercion
|
||||
:muuntaja formats/instance
|
||||
:swagger {:id ::api}
|
||||
:middleware [parameters/parameters-middleware
|
||||
muuntaja/format-negotiate-middleware
|
||||
muuntaja/format-response-middleware
|
||||
exception/exception-middleware
|
||||
muuntaja/format-request-middleware
|
||||
coercion/coerce-request-middleware
|
||||
coercion/coerce-response-middleware]}))
|
||||
|
||||
(derive :reitit.routes/api :reitit/routes)
|
||||
|
||||
(defmethod ig/init-key :reitit.routes/api
|
||||
[_ {:keys [base-path]
|
||||
:or {base-path ""}
|
||||
:as opts}]
|
||||
[base-path (route-data opts) (api-routes opts)])
|
||||
Reference in New Issue
Block a user