Compare commits

2 Commits
Author SHA1 Message Date
magnus 7c68220404 Merge branch 'fastapi' 2026-03-13 12:34:10 +01:00
magnusandClaude Opus 4.6 c999d831e7 Rewrite btcprice from Clojure to Python FastAPI
Replace the Clojure Kit application with a Python FastAPI app that
produces identical HTML output. WebSocket proxy, Danish number
formatting, and HTMX integration all preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:33:03 +01:00
37 changed files with 277 additions and 780 deletions
+3 -12
View File
@@ -1,15 +1,6 @@
/target __pycache__/
/classes *.pyc
/checkouts .venv/
profiles.clj
pom.xml
pom.xml.asc
*.jar
*.class
/.lein-*
/.nrepl-port
/.cpcache
/.clj-kondo
/node_modules /node_modules
/log /log
.DS_Store .DS_Store
+7 -35
View File
@@ -1,15 +1,12 @@
# btcprice # btcprice
Bitcoin price tracker UI — Clojure Kit web application. Serves the HTML/Tailwind UI. Connects to btcdata for live price data via WebSocket. Bitcoin price tracker UI — Python FastAPI web application. Serves the HTML/Tailwind UI. Connects to btcdata for live price data via WebSocket.
## Build & Development Commands ## Build & Development Commands
- `make run` — Start dev server on port 4000 - `make run` — Start dev server on port 4000
- `make repl` — Start nREPL
- `make test` — Run tests
- `make tailwind` — Watch Tailwind CSS for changes - `make tailwind` — Watch Tailwind CSS for changes
- `make uberjar` — Build production JAR - `make test` — Run tests
- `docker compose up -d` — Start btcprice + btcdata containers
## Docker ## Docker
@@ -22,21 +19,11 @@ Docker runs alongside local dev on different ports:
btcprice's docker-compose includes btcdata via `include: ../btcdata/docker-compose.yml`. btcprice's docker-compose includes btcdata via `include: ../btcdata/docker-compose.yml`.
## REPL Commands
```clojure
(dev-prep!) ;; Prepare dev system
(go) ;; Start system
(reset) ;; Reload code & restart
(halt) ;; Stop system
```
## Architecture ## Architecture
- **Framework:** Kit (Integrant-based) - **Framework:** FastAPI
- **Server:** Undertow - **Server:** Uvicorn
- **Routing:** Reitit - **Templates:** Inline HTML strings + HTMX (WebSocket extension)
- **Templates:** Hiccup + HTMX (WebSocket extension)
- **Styling:** Tailwind CSS v4 - **Styling:** Tailwind CSS v4
- **Data backend:** btcdata (separate service at BTCDATA_URL, default http://localhost:4100) - **Data backend:** btcdata (separate service at BTCDATA_URL, default http://localhost:4100)
- **No database** — all data comes from btcdata - **No database** — all data comes from btcdata
@@ -44,21 +31,6 @@ btcprice's docker-compose includes btcdata via `include: ../btcdata/docker-compo
## Source Layout ## Source Layout
``` ```
src/clj/pmagnus/btcprice/ app/
── core.clj # App entry point ── main.py # FastAPI app: routes, WebSocket proxy, HTML rendering
├── config.clj # System config loader
└── web/
├── handler.clj # Ring handler + routes
├── htmx.clj # page/fragment macros
├── controllers/
│ └── health.clj # Health check
├── middleware/
│ ├── core.clj # Base middleware
│ ├── exception.clj # Exception handling
│ └── formats.clj # Content negotiation
└── routes/
├── api.clj # /api routes (JSON)
├── ui.clj # UI routes (HTML + HTMX WebSocket)
├── ws_proxy.clj # WebSocket proxy to btcdata
└── utils.clj # Route utilities
``` ```
+9 -12
View File
@@ -4,25 +4,22 @@ WORKDIR /build
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY resources/css/ resources/css/ COPY resources/css/ resources/css/
COPY src/ src/ COPY app/ app/
COPY tailwind.config.js ./ COPY tailwind.config.js ./
RUN npm run css:build RUN npm run css:build
FROM clojure:temurin-21-tools-deps-alpine AS build FROM python:3.13-slim
WORKDIR /build WORKDIR /app
COPY deps.edn build.clj ./ COPY requirements.txt .
RUN clj -Sforce -P RUN pip install --no-cache-dir -r requirements.txt
COPY . .
COPY app/ app/
COPY resources/public/ resources/public/
COPY --from=css /build/resources/public/css/output.css resources/public/css/output.css COPY --from=css /build/resources/public/css/output.css resources/public/css/output.css
RUN clj -T:build all
FROM eclipse-temurin:21-jre-alpine
COPY --from=build /build/target/btcprice-standalone.jar /btcprice/btcprice-standalone.jar
EXPOSE 4040 EXPOSE 4040
ENV PORT=4040 ENV PORT=4040
ENV BTCDATA_URL=http://btcdata:4101 ENV BTCDATA_URL=http://btcdata:4101
CMD ["java", "-jar", "/btcprice/btcprice-standalone.jar"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "4040"]
+6 -12
View File
@@ -1,25 +1,19 @@
include .env include .env
export export
.PHONY: clean run run-docker repl tailwind test uberjar .PHONY: clean run run-docker tailwind test
clean: clean:
rm -rf target rm -rf __pycache__ app/__pycache__
run: run:
clj -M:dev -e "(dev-prep!) (go)" -r .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 4000 --reload
run-docker: run-local:
BTCDATA_URL=http://localhost:4101 clj -M:dev -e "(dev-prep!) (go)" -r BTCDATA_URL=$(BTCDATA_LOCAL_URL) .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 4000 --reload
repl:
clj -M:dev:nrepl
tailwind: tailwind:
npm run css:watch npm run css:watch
test: test:
clj -M:test .venv/bin/python -m pytest tests/
uberjar:
clj -T:build all
+246
View File
@@ -0,0 +1,246 @@
import json
import logging
import os
from datetime import datetime
import websockets
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("btcprice")
BTCDATA_URL = os.environ.get("BTCDATA_URL", "http://localhost:4100")
app = FastAPI()
app.mount("/css", StaticFiles(directory="resources/public/css"), name="css")
@app.get("/favicon.ico", include_in_schema=False)
async def favicon():
return FileResponse("resources/public/favicon.svg", media_type="image/svg+xml")
# --- HTML page ---
HOME_HTML = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/favicon.ico" type="image/svg+xml">
<title>BTC Price</title>
<link rel="stylesheet" href="/css/output.css">
<script src="https://unpkg.com/htmx.org@2/dist/htmx.min.js"></script>
<script src="https://unpkg.com/htmx-ext-ws@2/ws.js"></script>
</head>
<body>
<div class="mx-auto max-w-lg px-4 py-6">
<header class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">BTC Price</h1>
</header>
<div id="price-panel" hx-ext="ws" ws-connect="/ws/price">
<div id="price-display">
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
<p class="text-center text-gray-400 text-sm">Connecting...</p>
</div>
</div>
</div>
<div class="mt-4" hx-ext="ws" ws-connect="/ws/strike">
<div id="strike-display">
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
<p class="text-center text-gray-400 text-sm">Loading Strike rates...</p>
</div>
</div>
</div>
<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"></div>
</div>
</body>
</html>"""
@app.get("/", response_class=HTMLResponse)
async def home():
return HOME_HTML
# --- Health endpoint ---
@app.get("/api/health")
async def health():
return {"status": "up"}
# --- Formatting helpers (Danish locale) ---
def da_fmt(fmt: str, val: float) -> str:
"""Format a number using Danish locale conventions."""
if fmt == ",.2f":
# e.g. 12.345,67
int_part = int(val)
frac = abs(val - int_part)
int_str = f"{int_part:,}".replace(",", ".")
return f"{int_str},{frac:.2f}"[:-1].split(",")[0] + "," + f"{frac:.2f}"[2:]
if fmt == ",.0f":
return f"{int(round(val)):,}".replace(",", ".")
if fmt == ".4f":
return f"{val:.4f}".replace(".", ",")
if fmt == ",d":
return f"{int(val):,}".replace(",", ".")
return str(val)
# --- Price transform ---
def price_json_to_html(text: str) -> str:
data = json.loads(text)
p = float(data["price"])
pp = float(data["prev_price"]) if data.get("prev_price") is not None else None
if pp is None:
color = "text-gray-900"
elif p > pp:
color = "text-green-600"
elif p < pp:
color = "text-red-600"
else:
color = "text-gray-900"
recorded_at = data.get("recorded_at", "")
if recorded_at:
dt = datetime.fromisoformat(recorded_at.replace("Z", "+00:00"))
local_dt = dt.astimezone()
ts = local_dt.strftime("%d-%m %H:%M:%S")
else:
ts = ""
html = (
'<div id="price-display">'
'<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">'
'<div class="text-center">'
f'<p class="text-4xl font-bold {color}">${da_fmt(",.2f", p)}</p>'
)
if data.get("price_eur") is not None:
p_eur = float(data["price_eur"])
p_dkk = float(data["price_dkk"])
eur_rate = p_eur / p
dkk_rate = p_dkk / p
html += (
'<div class="flex justify-center gap-4 mt-3">'
f'<span class="text-2xl font-bold text-gray-600">\u20ac{da_fmt(",.0f", p_eur)}</span>'
f'<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">'
f'<span class="text-xs text-gray-400">EUR/USD {da_fmt(".4f", eur_rate)}</span>'
f'<span class="text-xs text-gray-400">DKK/USD {da_fmt(".4f", dkk_rate)}</span>'
"</div>"
)
html += f'<p class="text-xs text-gray-400 mt-2">Updated {ts}</p>'
html += "</div></div></div>"
return html
# --- Strike transform ---
STRIKE_PAIRS = [
("BTC/USDT", "$"),
("BTC/EUR", "\u20ac"),
("USDT/EUR", ""),
("EUR/BTC", ""),
]
def format_strike_amount(prefix: str, amount) -> str:
if amount is None:
return "\u2014"
v = float(amount)
if prefix in ("$", "\u20ac"):
return f'{prefix}{da_fmt(",.2f", v)}'
return str(amount)
def strike_json_to_html(text: str) -> str:
rates = json.loads(text)
sats = rates.get("quote-sats")
html = (
'<div id="strike-display">'
'<div class="rounded-2xl shadow-sm border border-gray-200 p-6" style="background:#f3f4f6;">'
)
if sats is not None:
html += (
'<p class="text-center text-2xl font-bold mb-3" style="color:#1e3a5f;">'
f'{da_fmt(",d", sats)} <span style="color:#1e3a5f;">sats</span></p>'
)
html += '<div style="display:grid;grid-template-columns:1fr;row-gap:0.25rem;">'
for pair, prefix in STRIKE_PAIRS:
amount = rates.get(pair)
html += (
'<div style="display:flex;justify-content:space-between;align-items:center;padding:0.25rem 0;">'
f'<span class="text-sm text-gray-500">{pair}</span>'
f'<span class="text-sm font-bold text-gray-900">{format_strike_amount(prefix, amount)}</span>'
"</div>"
)
html += "</div></div></div>"
return html
# --- WebSocket proxy ---
def _btcdata_ws_url(path: str) -> str:
return BTCDATA_URL.replace("http", "ws", 1) + path
async def _ws_proxy(browser_ws: WebSocket, upstream_path: str, transform):
await browser_ws.accept()
upstream_url = _btcdata_ws_url(upstream_path)
logger.info("Browser connected, proxying to %s", upstream_url)
try:
async with websockets.connect(upstream_url, ping_interval=None) as upstream:
async for message in upstream:
if not message:
continue
try:
html = transform(message)
await browser_ws.send_text(html)
except WebSocketDisconnect:
break
except Exception:
logger.debug("Error forwarding to browser", exc_info=True)
break
except WebSocketDisconnect:
pass
except Exception:
logger.debug("Upstream connection error", exc_info=True)
finally:
try:
await browser_ws.close()
except Exception:
pass
@app.websocket("/ws/price")
async def ws_price(websocket: WebSocket):
await _ws_proxy(websocket, "/api/price/ws", price_json_to_html)
@app.websocket("/ws/strike")
async def ws_strike(websocket: WebSocket):
await _ws_proxy(websocket, "/api/strike/ws", strike_json_to_html)
-13
View File
@@ -1,13 +0,0 @@
{:tasks
{:run {:doc "Start dev server"
:task (shell "clj -M:dev")}
:nrepl {:doc "Start nREPL"
:task (shell "clj -M:dev:nrepl")}
:cider {:doc "Start CIDER"
:task (shell "clj -M:dev:cider")}
:test {:doc "Run tests"
:task (shell "clj -M:test")}
:uberjar {:doc "Build uberjar"
:task (shell "clj -T:build all")}
:format {:doc "Format code"
:task (shell "clj -M:dev -m cljstyle.main fix src test")}}}
-28
View File
@@ -1,28 +0,0 @@
(ns build
(:require [clojure.tools.build.api :as b]))
(def lib 'pmagnus/btcprice)
(def version "0.0.1-SNAPSHOT")
(def target-dir "target")
(def class-dir (str target-dir "/classes"))
(def basis (delay (b/create-basis {:project "deps.edn"})))
(def uber-file (str target-dir "/btcprice-standalone.jar"))
(defn clean [_]
(b/delete {:path target-dir}))
(defn uber [_]
(clean nil)
(b/copy-dir {:src-dirs ["src/clj" "resources" "env/prod/resources" "env/prod/clj"]
:target-dir class-dir})
(b/compile-clj {:basis @basis
:ns-compile '[pmagnus.btcprice.core]
:class-dir class-dir})
(b/uber {:class-dir class-dir
:uber-file uber-file
:basis @basis
:main 'pmagnus.btcprice.core}))
(defn all [_]
(uber nil))
-55
View File
@@ -1,55 +0,0 @@
{:paths ["src/clj" "resources"]
:deps {org.clojure/clojure {:mvn/version "1.12.3"}
;; Kit
io.github.kit-clj/kit-core {:mvn/version "1.0.6"}
;; Logging
ch.qos.logback/logback-classic {:mvn/version "1.5.20"}
;; HTTP Server
io.github.kit-clj/kit-undertow {:mvn/version "1.0.10"}
;; Routing
metosin/reitit {:mvn/version "0.9.2"}
metosin/reitit-ring {:mvn/version "0.9.2"}
metosin/reitit-middleware {:mvn/version "0.9.2"}
metosin/reitit-swagger {:mvn/version "0.9.2"}
metosin/reitit-swagger-ui {:mvn/version "0.9.2"}
metosin/reitit-malli {:mvn/version "0.9.2"}
;; Web
ring/ring-core {:mvn/version "1.15.3"}
ring/ring-defaults {:mvn/version "0.7.0"}
metosin/ring-http-response {:mvn/version "0.9.5"}
hiccup/hiccup {:mvn/version "2.0.0"}
;; Serialization
metosin/muuntaja {:mvn/version "0.6.11"}
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"}}
:ns-default build}
:dev {:extra-paths ["env/dev/clj" "env/dev/resources" "test/clj"]
:extra-deps {integrant/repl {:mvn/version "0.5.0"}
criterium/criterium {:mvn/version "0.4.6"}
expound/expound {:mvn/version "0.9.0"}
com.lambdaisland/classpath {:mvn/version "0.4.44"}
ring/ring-devel {:mvn/version "1.15.3"}}}
:test {:extra-paths ["env/test/resources"]
:extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"}
peridot/peridot {:mvn/version "0.5.4"}
clj-commons/byte-streams {:mvn/version "0.3.4"}}
:main-opts ["-m" "cognitect.test-runner"]
:exec-fn cognitect.test-runner.api/test}
:nrepl {:extra-deps {nrepl/nrepl {:mvn/version "1.3.1"}}
:main-opts ["-m" "nrepl.cmdline"
"--middleware" "[integrant.repl/middleware]"]}
:cider {:extra-deps {cider/cider-nrepl {:mvn/version "0.55.7"}}
:main-opts ["-m" "nrepl.cmdline"
"--middleware" "[cider.nrepl/cider-middleware]"]}}}
-4
View File
@@ -1,4 +0,0 @@
(ns pmagnus.btcprice.dev-middleware)
(defn wrap-dev [handler _opts]
handler)
-11
View File
@@ -1,11 +0,0 @@
(ns pmagnus.btcprice.env
(:require
[clojure.tools.logging :as log]
[pmagnus.btcprice.dev-middleware :refer [wrap-dev]]))
(def defaults
{:init (fn [] (log/info "\n-=[btcprice starting...dev/test profile]=-"))
:start (fn [] (log/info "\n-=[btcprice started...dev/test profile]=-"))
:stop (fn [] (log/info "\n-=[btcprice has shut down]=-"))
:middleware wrap-dev
:opts {:profile :dev}})
-25
View File
@@ -1,25 +0,0 @@
(ns user
(:require
[integrant.core :as ig]
[integrant.repl :refer [go halt reset reset-all set-prep!]]
[integrant.repl.state :as state]
[pmagnus.btcprice.config :as config]
[pmagnus.btcprice.core]))
(defn dev-prep! []
(set-prep!
(fn []
(-> (config/system-config {:profile :dev})
(ig/expand)))))
(defn test-prep! []
(set-prep!
(fn []
(-> (config/system-config {:profile :test})
(ig/expand)))))
(comment
(dev-prep!)
(go)
(reset)
(halt))
-15
View File
@@ -1,15 +0,0 @@
<configuration>
<statusListener class="ch.qos.logback.core.status.NopStatusListener" />
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
<logger name="pmagnus.btcprice" level="DEBUG" />
<logger name="org.eclipse.jetty" level="WARN" />
<logger name="io.undertow" level="WARN" />
<logger name="org.xnio" level="WARN" />
</configuration>
-10
View File
@@ -1,10 +0,0 @@
(ns pmagnus.btcprice.env
(:require
[clojure.tools.logging :as log]))
(def defaults
{:init (fn [] (log/info "\n-=[btcprice starting]=-"))
:start (fn [] (log/info "\n-=[btcprice started successfully]=-"))
:stop (fn [] (log/info "\n-=[btcprice has shut down successfully]=-"))
:middleware (fn [handler _] handler)
:opts {:profile :prod}})
-14
View File
@@ -1,14 +0,0 @@
<configuration>
<statusListener class="ch.qos.logback.core.status.NopStatusListener" />
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
<logger name="org.eclipse.jetty" level="WARN" />
<logger name="io.undertow" level="WARN" />
<logger name="org.xnio" level="WARN" />
</configuration>
-11
View File
@@ -1,11 +0,0 @@
<configuration>
<statusListener class="ch.qos.logback.core.status.NopStatusListener" />
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="ERROR">
<appender-ref ref="STDOUT" />
</root>
</configuration>
-8
View File
@@ -1,8 +0,0 @@
{:full-name "pmagnus/btcprice"
:ns-name "pmagnus.btcprice"
:sanitized "pmagnus/btcprice"
:name "btcprice"
:modules {:root "modules"
:repositories [{:url "https://github.com/kit-clj/modules.git"
:tag "master"
:name "kit-modules"}]}}
-1
View File
@@ -1 +0,0 @@
{}
+3
View File
@@ -0,0 +1,3 @@
fastapi>=0.115
uvicorn[standard]>=0.34
websockets>=14.0
+1 -1
View File
@@ -1,2 +1,2 @@
@import "tailwindcss"; @import "tailwindcss";
@source "../../src/**/*.clj"; @source "../../app/**/*.py";
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90"></text></svg>

After

Width:  |  Height:  |  Size: 109 B

-43
View File
@@ -1,43 +0,0 @@
{:system/env #profile {:dev :dev :test :test :prod :prod}
:server/http
{:port #long #or [#env PORT 4000]
:host #or [#env HTTP_HOST "0.0.0.0"]
:handler #ig/ref :handler/ring}
:handler/ring
{:router #ig/ref :router/core
:api-path "/api"
:site-defaults-config
{:params {:keywordize true
:multipart true
:nested true}
:cookies true
:session {:flash true
:cookie-attrs {:http-only true
:same-site :strict}}
:security {:anti-forgery false
:xss-protection {:enable? true :mode :block}
:frame-options :sameorigin}
:static {:resources "public"}
:responses {:not-modified-responses true
:absolute-redirects true
:content-types true
:default-charset "utf-8"}}}
:router/routes
{:routes #ig/refset :reitit/routes}
:router/core
{:routes #ig/ref :router/routes
:env #ig/ref :system/env}
:reitit.routes/api
{:base-path "/api"}
:reitit.routes/ui
{:base-path ""}
:reitit.routes/ws-proxy
{:base-path ""
:btcdata-url #or [#env BTCDATA_URL "http://localhost:4100"]}}
-8
View File
@@ -1,8 +0,0 @@
(ns pmagnus.btcprice.config
(:require
[kit.config :as config]))
(def ^:const system-filename "system.edn")
(defn system-config [options]
(config/read-config system-filename options))
-38
View File
@@ -1,38 +0,0 @@
(ns pmagnus.btcprice.core
(:require
[clojure.tools.logging :as log]
[integrant.core :as ig]
[pmagnus.btcprice.config :as config]
[pmagnus.btcprice.env :refer [defaults]]
;; Edges
[kit.edge.server.undertow]
[pmagnus.btcprice.web.handler]
;; Routes
[pmagnus.btcprice.web.routes.api]
[pmagnus.btcprice.web.routes.ui]
[pmagnus.btcprice.web.routes.ws-proxy])
(:gen-class))
(defonce system (atom nil))
(defn stop-app []
((or (:stop defaults) (fn [])))
(some-> (deref system) (ig/halt!))
(shutdown-agents))
(defn start-app [& [params]]
((or (:init defaults) (fn [])))
(->> (config/system-config (or params {}))
(ig/expand)
(ig/init)
(reset! system))
((or (:start defaults) (fn []))))
(defn -main [& _]
(Thread/setDefaultUncaughtExceptionHandler
(reify Thread$UncaughtExceptionHandler
(uncaughtException [_ _thread ex]
(log/error ex "Uncaught exception"))))
(start-app)
(.addShutdownHook (Runtime/getRuntime) (Thread. stop-app)))
@@ -1,11 +0,0 @@
(ns pmagnus.btcprice.web.controllers.health
(:require
[ring.util.http-response :as response])
(:import
[java.lang.management ManagementFactory]))
(defn healthcheck! [_req]
(response/ok
{:time (str (java.time.Instant/now))
:up-time (.. ManagementFactory getRuntimeMXBean getUptime)
:app {:status "up"}}))
-31
View File
@@ -1,31 +0,0 @@
(ns pmagnus.btcprice.web.handler
(:require
[integrant.core :as ig]
[reitit.ring :as ring]
[pmagnus.btcprice.web.middleware.core :as middleware]))
(defmethod ig/init-key :handler/ring
[_ {:keys [router api-path] :as opts}]
(ring/ring-handler
(router)
(ring/routes
(ring/create-resource-handler {:path "/"})
(when (some? api-path)
(reitit.ring/create-default-handler))
(ring/create-default-handler
{:not-found (constantly {:status 404 :body "Page not found"})
:method-not-allowed (constantly {:status 405 :body "Not allowed"})
:not-acceptable (constantly {:status 406 :body "Not acceptable"})}))
{:middleware [(middleware/wrap-base opts)]}))
(defmethod ig/init-key :router/routes
[_ {:keys [routes]}]
(mapv (fn [route]
(if (fn? route) (route) route))
routes))
(defmethod ig/init-key :router/core
[_ {:keys [routes env]}]
(if (= env :dev)
(fn [] (ring/router routes))
(constantly (ring/router routes))))
-32
View File
@@ -1,32 +0,0 @@
(ns pmagnus.btcprice.web.htmx
(:require
[hiccup2.core :as h]))
(defmacro page
[opts & content]
`(let [opts# ~opts]
{:status 200
:headers {"Content-Type" "text/html; charset=utf-8"}
:body (str
(h/html
(hiccup2.core/raw "<!DOCTYPE html>")
[:html {:lang (or (:lang opts#) "en")}
[:head
[:meta {:charset "utf-8"}]
[:meta {:name "viewport" :content "width=device-width, initial-scale=1"}]
[:meta {:name "apple-mobile-web-app-capable" :content "yes"}]
[:meta {:name "apple-mobile-web-app-status-bar-style" :content "black-translucent"}]
[:title (or (:title opts#) "BTC Price")]
[:link {:rel "stylesheet" :href "/css/output.css"}]
[:script {:src "https://unpkg.com/htmx.org@2/dist/htmx.min.js"}]
[:script {:src "https://unpkg.com/htmx-ext-ws@2/ws.js"}]]
[:body
[:div.mx-auto.max-w-lg.px-4.py-6
~@content]]]))}))
(defmacro fragment
[opts & content]
`(let [_opts# ~opts]
{:status 200
:headers {"Content-Type" "text/html; charset=utf-8"}
:body (str (h/html ~@content))}))
@@ -1,19 +0,0 @@
(ns pmagnus.btcprice.web.middleware.core
(:require
[ring.middleware.defaults :as defaults]))
(defn- wrap-nil-guard
"Discard responses with no :status — these are artifacts of ring-defaults
middleware wrapping a nil response (e.g., from a raw WebSocket upgrade).
Passes through :undertow/websocket responses for the adapter to handle."
[handler]
(fn [request]
(let [resp (handler request)]
(when (or (:status resp) (:undertow/websocket resp)) resp))))
(defn wrap-base [{:keys [site-defaults-config]}]
(fn [handler]
(-> handler
(defaults/wrap-defaults
(or site-defaults-config defaults/site-defaults))
(wrap-nil-guard))))
@@ -1,21 +0,0 @@
(ns pmagnus.btcprice.web.middleware.exception
(:require
[clojure.tools.logging :as log]
[reitit.ring.middleware.exception :as exception]))
(defn- handler [message exception request]
(let [uri (:uri request)]
(log/error exception (str message " at " uri))
{:status 500
:body {:message message
:exception (.getClass exception)
:data (ex-data exception)
:uri uri}}))
(def exception-middleware
(exception/create-exception-middleware
(merge
exception/default-handlers
{::exception/default (partial handler "Internal error")
::exception/wrap (fn [handler e request]
(handler e request))})))
@@ -1,12 +0,0 @@
(ns pmagnus.btcprice.web.middleware.formats
(:require
[luminus-transit.time :as time]
[muuntaja.core :as m]))
(def instance
(m/create
(-> m/default-options
(update-in [:formats "application/transit+json" :decoder-opts]
(partial merge time/time-deserialization-handlers))
(update-in [:formats "application/transit+json" :encoder-opts]
(partial merge time/time-serialization-handlers)))))
@@ -1,41 +0,0 @@
(ns pmagnus.btcprice.web.routes.api
(:require
[integrant.core :as ig]
[pmagnus.btcprice.web.controllers.health :as health]
[pmagnus.btcprice.web.middleware.exception :as exception]
[pmagnus.btcprice.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]))
(defn- api-routes [_opts]
[["/swagger.json"
{:get {:no-doc true
:swagger {:info {:title "btcprice API"}}
:handler (swagger/create-swagger-handler)}}]
["/health"
{:get health/healthcheck!}]])
(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)])
@@ -1,48 +0,0 @@
(ns pmagnus.btcprice.web.routes.ui
(:require
[integrant.core :as ig]
[pmagnus.btcprice.web.htmx :refer [page]]
[pmagnus.btcprice.web.middleware.exception :as exception]
[pmagnus.btcprice.web.middleware.formats :as formats]
[reitit.ring.middleware.muuntaja :as muuntaja]
[reitit.ring.middleware.parameters :as parameters]))
(defn- home-page [_opts _req]
(page {:title "BTC Price"}
[:header.text-center.mb-8
[:h1.text-3xl.font-bold.text-gray-900 "BTC Price"]
[:p.text-sm.text-gray-500.mt-1 "Bitcoin price tracker"]]
[:div#price-panel {:hx-ext "ws" :ws-connect "/ws/price"}
[:div#price-display
[: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 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]
[["/"
{:get (fn [req] (home-page opts req))}]])
(defn route-data [opts]
(merge
opts
{:muuntaja formats/instance
:middleware [parameters/parameters-middleware
muuntaja/format-response-middleware
exception/exception-middleware]}))
(derive :reitit.routes/ui :reitit/routes)
(defmethod ig/init-key :reitit.routes/ui
[_ {:keys [base-path]
:or {base-path ""}
:as opts}]
[base-path (route-data opts) (ui-routes opts)])
@@ -1,9 +0,0 @@
(ns pmagnus.btcprice.web.routes.utils)
(def route-data-path [:reitit.core/match :data])
(defn route-data [req]
(get-in req route-data-path))
(defn route-data-key [req k]
(get (route-data req) k))
@@ -1,159 +0,0 @@
(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]
(when (and ch (.isOpen ch))
(try (.close ch) (catch Exception _))))
(defn- close-upstream-quietly [^java.net.http.WebSocket ws]
(when ws
(try (.sendClose ws java.net.http.WebSocket/NORMAL_CLOSURE "") (catch Exception _))))
(defn- connect-upstream
"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]
(.request ws 1))
(onText [_ ws data last?]
(let [text (str data)]
(try
(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))))
(.request ws 1)
(CompletableFuture/completedFuture nil))
(onClose [_ _ws status-code _reason]
(log/debug "Upstream closed" status-code)
(close-quietly browser-ch))
(onError [_ _ws error]
(log/debug error "Upstream error")
(close-quietly browser-ch)))]
(-> (.newWebSocketBuilder client)
^WebSocket$Builder identity
(.buildAsync (URI. btcdata-ws-url) listener)
(.join))))
(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 xf)))
:on-close-message
(fn [_]
(log/debug "Browser closed")
(close-upstream-quietly @upstream-atom))}}))
;; --- 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" "\u20AC"]
["USDT/EUR" ""]
["EUR/BTC" ""]])
(defn- format-strike-amount [prefix amount]
(if amount
(let [v (double (bigdec amount))]
(case prefix
"$" (str prefix (da-fmt "%,.2f" v))
"\u20AC" (str prefix (da-fmt "%,.2f" v))
amount))
"\u2014"))
(defn- strike-json->html [text]
(let [rates (json/read-str text)
sats (get rates "quote-sats")]
(str "<div id=\"strike-display\">"
"<div class=\"rounded-2xl shadow-sm border border-gray-200 p-6\" style=\"background:#f3f4f6;\">"
(if sats
(str "<p class=\"text-center text-2xl font-bold mb-3\" style=\"color:#1e3a5f;\">"
(da-fmt "%,d" sats) " <span style=\"color:#1e3a5f;\">sats</span></p>")
"")
"<div style=\"display:grid;grid-template-columns:1fr;row-gap:0.25rem;\">"
(apply str
(for [[pair prefix] strike-pairs
:let [amount (get rates pair)]]
(str "<div style=\"display:flex;justify-content:space-between;align-items:center;padding:0.25rem 0;\">"
"<span class=\"text-sm text-gray-500\">" pair "</span>"
"<span class=\"text-sm font-bold text-gray-900\">"
(format-strike-amount prefix amount)
"</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)
(defmethod ig/init-key :reitit.routes/ws-proxy
[_ {:keys [base-path]
:or {base-path ""}
:as opts}]
[base-path {} (ws-proxy-routes opts)])
+1 -1
View File
@@ -1,5 +1,5 @@
module.exports = { module.exports = {
content: ["./src/**/*.clj", "./resources/**/*.html"], content: ["./app/**/*.py", "./resources/**/*.html"],
theme: { extend: {} }, theme: { extend: {} },
plugins: [] plugins: []
} }
-6
View File
@@ -1,6 +0,0 @@
(ns pmagnus.btcprice.core-test
(:require
[clojure.test :refer [deftest is]]))
(deftest app-loads-test
(is (= 1 1)))
-21
View File
@@ -1,21 +0,0 @@
(ns pmagnus.btcprice.test-utils
(:require
[integrant.core :as ig]
[integrant.repl.state :as state]
[pmagnus.btcprice.config :as config]
[pmagnus.btcprice.core :as core]))
(defn system-state []
(or @core/system state/system))
(defn system-fixture []
(fn [f]
(let [system (-> (config/system-config {:profile :test})
(ig/prep)
(ig/init))]
(reset! core/system system)
(try
(f)
(finally
(ig/halt! system)
(reset! core/system nil))))))
@@ -1,13 +0,0 @@
(ns pmagnus.btcprice.web.request-test
(:require
[clojure.test :refer [deftest is use-fixtures]]
[pmagnus.btcprice.test-utils :as tu]))
(use-fixtures :once (tu/system-fixture))
(deftest health-request-test
(let [sys (tu/system-state)
app (get sys :handler/ring)]
(when app
(let [resp (app {:request-method :get :uri "/api/health"})]
(is (= 200 (:status resp)))))))