Compare commits
23
Commits
a50256a042
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c68220404 | ||
|
|
c999d831e7 | ||
|
|
ae676f2b6c | ||
|
|
6b4c8034f1 | ||
|
|
161737d4c7 | ||
|
|
5990b8d735 | ||
|
|
134ee27ca6 | ||
|
|
71918ee4fd | ||
|
|
a9b7874f36 | ||
|
|
32a1509482 | ||
|
|
6afaa228c3 | ||
|
|
5c1a080831 | ||
|
|
13a53a26d0 | ||
|
|
02cc607e73 | ||
|
|
ce343dfcf6 | ||
|
|
397d9f7b5c | ||
|
|
c688846a26 | ||
|
|
30aa52cdc7 | ||
|
|
553dac3069 | ||
|
|
2c576aff4e | ||
|
|
201155c0b2 | ||
|
|
8169796eb9 | ||
|
|
08793c5099 |
@@ -0,0 +1,5 @@
|
|||||||
|
target/
|
||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
.cpcache/
|
||||||
|
.nrepl-port
|
||||||
+3
-12
@@ -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
|
||||||
|
|||||||
@@ -1,55 +1,36 @@
|
|||||||
# btcprice
|
# btcprice
|
||||||
|
|
||||||
Bitcoin price tracker - Clojure Kit web application.
|
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 3000
|
- `make run` — Start dev server on port 4000
|
||||||
- `make repl` — Start nREPL on port 7888
|
|
||||||
- `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 PostgreSQL
|
|
||||||
|
|
||||||
## REPL Commands
|
## Docker
|
||||||
|
|
||||||
```clojure
|
Docker runs alongside local dev on different ports:
|
||||||
(dev-prep!) ;; Prepare dev system
|
|
||||||
(go) ;; Start system
|
| Service | Local dev | Docker |
|
||||||
(reset) ;; Reload code & restart
|
|----------|----------------|----------------|
|
||||||
(halt) ;; Stop system
|
| btcprice | localhost:4000 | localhost:4041 |
|
||||||
(reset-db) ;; Drop & re-migrate database
|
| btcdata | localhost:4100 | localhost:4101 |
|
||||||
(migrate) ;; Run pending migrations
|
|
||||||
(rollback) ;; Rollback last migration
|
btcprice's docker-compose includes btcdata via `include: ../btcdata/docker-compose.yml`.
|
||||||
(query-fn) ;; Get database query function
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **Framework:** Kit (Integrant-based)
|
- **Framework:** FastAPI
|
||||||
- **Server:** Undertow
|
- **Server:** Uvicorn
|
||||||
- **Routing:** Reitit
|
- **Templates:** Inline HTML strings + HTMX (WebSocket extension)
|
||||||
- **Templates:** Hiccup + HTMX
|
|
||||||
- **Styling:** Tailwind CSS v4
|
- **Styling:** Tailwind CSS v4
|
||||||
- **Database:** PostgreSQL + conman + Migratus
|
- **Data backend:** btcdata (separate service at BTCDATA_URL, default http://localhost:4100)
|
||||||
|
- **No database** — all data comes from btcdata
|
||||||
|
|
||||||
## 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)
|
|
||||||
└── utils.clj # Route utilities
|
|
||||||
```
|
```
|
||||||
|
|||||||
+17
-6
@@ -1,14 +1,25 @@
|
|||||||
FROM clojure:openjdk-25 AS build
|
FROM node:22-alpine AS css
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY . .
|
COPY package.json package-lock.json ./
|
||||||
RUN clj -Sforce -T:build all
|
RUN npm ci
|
||||||
|
COPY resources/css/ resources/css/
|
||||||
|
COPY app/ app/
|
||||||
|
COPY tailwind.config.js ./
|
||||||
|
RUN npm run css:build
|
||||||
|
|
||||||
FROM azul/zulu-openjdk-alpine:25
|
FROM python:3.13-slim
|
||||||
|
|
||||||
COPY --from=build /build/target/btcprice-standalone.jar /btcprice/btcprice-standalone.jar
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app/ app/
|
||||||
|
COPY resources/public/ resources/public/
|
||||||
|
COPY --from=css /build/resources/public/css/output.css resources/public/css/output.css
|
||||||
|
|
||||||
EXPOSE 4040
|
EXPOSE 4040
|
||||||
ENV PORT=4040
|
ENV PORT=4040
|
||||||
|
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"]
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
include .env
|
include .env
|
||||||
export
|
export
|
||||||
|
|
||||||
.PHONY: clean run 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
|
||||||
|
|
||||||
repl:
|
run-local:
|
||||||
clj -M:dev:nrepl
|
BTCDATA_URL=$(BTCDATA_LOCAL_URL) .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 4000 --reload
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# btcprice
|
||||||
|
|
||||||
|
Bitcoin price tracker UI. Serves an HTML page with live price updates streamed from [btcdata](../btcdata) via WebSocket using HTMX.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start btcdata first (provides price data)
|
||||||
|
cd ../btcdata && make run
|
||||||
|
|
||||||
|
# Local dev
|
||||||
|
make run # starts on port 4000
|
||||||
|
make tailwind # watch Tailwind CSS (separate terminal)
|
||||||
|
|
||||||
|
# Docker (starts both btcprice + btcdata)
|
||||||
|
docker compose up -d # btcprice on 4041, btcdata on 4101
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `PORT` | `4000` | HTTP server port |
|
||||||
|
| `BTCDATA_URL` | `http://192.168.50.42:4100` | btcdata service URL (browser connects via WebSocket) |
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
The server renders an HTML page with HTMX and the WebSocket extension (`hx-ext="ws"`). HTMX connects directly to btcdata's WebSocket endpoint (`BTCDATA_URL/api/price/ws`), which sends HTML fragments. HTMX swaps the received `<div id="price-display">` into the DOM automatically. Price changes are color-coded (green up, red down). Reconnection is handled by HTMX with exponential backoff.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
Clojure · Kit · Undertow · Reitit · Hiccup · HTMX · Tailwind CSS v4
|
||||||
+246
@@ -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)
|
||||||
@@ -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")}}}
|
|
||||||
@@ -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))
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
{:paths ["src/clj" "resources"]
|
|
||||||
:deps {org.clojure/clojure {:mvn/version "1.12.3"}
|
|
||||||
org.clojure/data.json {:mvn/version "2.5.1"}
|
|
||||||
|
|
||||||
;; 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"}
|
|
||||||
|
|
||||||
;; Database
|
|
||||||
io.github.kit-clj/kit-postgres {:mvn/version "1.0.7"}
|
|
||||||
io.github.kit-clj/kit-sql-conman {:mvn/version "1.10.5"}
|
|
||||||
io.github.kit-clj/kit-sql-migratus {:mvn/version "1.0.5"}}
|
|
||||||
|
|
||||||
: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]"]}}}
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
networks:
|
||||||
|
btc-network:
|
||||||
|
name: btc-network
|
||||||
|
external: true
|
||||||
|
|
||||||
|
services:
|
||||||
|
btcprice:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- btc-network
|
||||||
|
ports:
|
||||||
|
- "4041:4040"
|
||||||
|
environment:
|
||||||
|
BTCDATA_URL: "http://btcdata:${DOCKER_BTCDATA_PORT:-4102}"
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
(ns pmagnus.btcprice.dev-middleware)
|
|
||||||
|
|
||||||
(defn wrap-dev [handler _opts]
|
|
||||||
handler)
|
|
||||||
Vendored
-11
@@ -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}})
|
|
||||||
Vendored
-50
@@ -1,50 +0,0 @@
|
|||||||
(ns user
|
|
||||||
(:require
|
|
||||||
[clojure.tools.logging :as log]
|
|
||||||
[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)))))
|
|
||||||
|
|
||||||
(defn reset-db []
|
|
||||||
(let [sys (or @pmagnus.btcprice.core/system
|
|
||||||
integrant.repl.state/system)]
|
|
||||||
(when-let [mig (:db.sql/migrations sys)]
|
|
||||||
(migratus.core/reset mig)
|
|
||||||
(log/info "Database reset complete"))))
|
|
||||||
|
|
||||||
(defn rollback []
|
|
||||||
(let [sys (or @pmagnus.btcprice.core/system
|
|
||||||
integrant.repl.state/system)]
|
|
||||||
(when-let [mig (:db.sql/migrations sys)]
|
|
||||||
(migratus.core/rollback mig))))
|
|
||||||
|
|
||||||
(defn migrate []
|
|
||||||
(let [sys (or @pmagnus.btcprice.core/system
|
|
||||||
integrant.repl.state/system)]
|
|
||||||
(when-let [mig (:db.sql/migrations sys)]
|
|
||||||
(migratus.core/migrate mig))))
|
|
||||||
|
|
||||||
(defn query-fn []
|
|
||||||
(let [sys (or @pmagnus.btcprice.core/system
|
|
||||||
integrant.repl.state/system)]
|
|
||||||
(:db.sql/query-fn sys)))
|
|
||||||
|
|
||||||
(comment
|
|
||||||
(dev-prep!)
|
|
||||||
(go)
|
|
||||||
(reset)
|
|
||||||
(halt))
|
|
||||||
Vendored
-15
@@ -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
@@ -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}})
|
|
||||||
Vendored
-14
@@ -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>
|
|
||||||
Vendored
-11
@@ -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>
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fastapi>=0.115
|
||||||
|
uvicorn[standard]>=0.34
|
||||||
|
websockets>=14.0
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@source "../../src/**/*.clj";
|
@source "../../app/**/*.py";
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
DROP TABLE binance_price;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
CREATE TABLE binance_price (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
price NUMERIC(18,8) NOT NULL,
|
|
||||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Migration files go here.
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -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 |
@@ -1,9 +0,0 @@
|
|||||||
-- queries for btcprice
|
|
||||||
|
|
||||||
-- :name insert-binance-price! :! :n
|
|
||||||
-- :doc Insert a Binance BTC price
|
|
||||||
INSERT INTO binance_price (price) VALUES (:price)
|
|
||||||
|
|
||||||
-- :name get-latest-binance-price :? :1
|
|
||||||
-- :doc Get the most recent Binance BTC price
|
|
||||||
SELECT price, recorded_at FROM binance_price ORDER BY id DESC LIMIT 1
|
|
||||||
@@ -1,58 +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}
|
|
||||||
|
|
||||||
:db.sql/connection
|
|
||||||
{:jdbc-url #env JDBC_URL}
|
|
||||||
|
|
||||||
:db.sql/query-fn
|
|
||||||
{:conn #ig/ref :db.sql/connection
|
|
||||||
:options {}
|
|
||||||
:filename "queries.sql"}
|
|
||||||
|
|
||||||
:db.sql/migrations
|
|
||||||
{:store :database
|
|
||||||
:db {:datasource #ig/ref :db.sql/connection}
|
|
||||||
:migrate-on-init? true}
|
|
||||||
|
|
||||||
:reitit.routes/api
|
|
||||||
{:base-path "/api"
|
|
||||||
:query-fn #ig/ref :db.sql/query-fn}
|
|
||||||
|
|
||||||
:reitit.routes/ui
|
|
||||||
{:base-path ""
|
|
||||||
:query-fn #ig/ref :db.sql/query-fn}
|
|
||||||
|
|
||||||
:ws/binance
|
|
||||||
{:query-fn #ig/ref :db.sql/query-fn
|
|
||||||
:uri "wss://stream.binance.com:9443/ws/btcusdt@trade"}}
|
|
||||||
@@ -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))
|
|
||||||
@@ -1,42 +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.db.postgres]
|
|
||||||
[kit.edge.db.sql.conman]
|
|
||||||
[kit.edge.db.sql.migratus]
|
|
||||||
[kit.edge.server.undertow]
|
|
||||||
[pmagnus.btcprice.web.handler]
|
|
||||||
;; Routes
|
|
||||||
[pmagnus.btcprice.web.routes.api]
|
|
||||||
[pmagnus.btcprice.web.routes.ui]
|
|
||||||
;; WebSocket clients
|
|
||||||
[pmagnus.btcprice.ws.binance])
|
|
||||||
(: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"}}))
|
|
||||||
@@ -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))))
|
|
||||||
@@ -1,31 +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.0.8"}]]
|
|
||||||
[: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,9 +0,0 @@
|
|||||||
(ns pmagnus.btcprice.web.middleware.core
|
|
||||||
(:require
|
|
||||||
[ring.middleware.defaults :as defaults]))
|
|
||||||
|
|
||||||
(defn wrap-base [{:keys [site-defaults-config]}]
|
|
||||||
(fn [handler]
|
|
||||||
(cond-> handler
|
|
||||||
true (defaults/wrap-defaults
|
|
||||||
(or site-defaults-config defaults/site-defaults)))))
|
|
||||||
@@ -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,42 +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]
|
|
||||||
[reitit.swagger-ui :as swagger-ui]))
|
|
||||||
|
|
||||||
(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,53 +0,0 @@
|
|||||||
(ns pmagnus.btcprice.web.routes.ui
|
|
||||||
(:require
|
|
||||||
[integrant.core :as ig]
|
|
||||||
[pmagnus.btcprice.web.htmx :refer [page fragment]]
|
|
||||||
[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 [_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.space-y-4
|
|
||||||
{:hx-get "/price" :hx-trigger "every 2s" :hx-swap "innerHTML"}
|
|
||||||
[:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6
|
|
||||||
[:p.text-center.text-gray-400.text-sm "Loading..."]]]))
|
|
||||||
|
|
||||||
(defn price-fragment [{:keys [query-fn]}]
|
|
||||||
(let [row (query-fn :get-latest-binance-price {})]
|
|
||||||
(fragment {}
|
|
||||||
[:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6
|
|
||||||
(if row
|
|
||||||
[:div.text-center
|
|
||||||
[:p.text-4xl.font-bold.text-gray-900
|
|
||||||
(str "$" (format "%,.2f" (double (:price row))))]
|
|
||||||
[:p.text-xs.text-gray-400.mt-2
|
|
||||||
(str "Updated " (:recorded_at row))]]
|
|
||||||
[:p.text-center.text-gray-400.text-sm "Waiting for data..."])])))
|
|
||||||
|
|
||||||
(defn- ui-routes [opts]
|
|
||||||
[["/"
|
|
||||||
{:get home-page}]
|
|
||||||
["/price"
|
|
||||||
{:get (fn [_req] (price-fragment opts))}]])
|
|
||||||
|
|
||||||
(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,85 +0,0 @@
|
|||||||
(ns pmagnus.btcprice.ws.binance
|
|
||||||
(:require
|
|
||||||
[clojure.data.json :as json]
|
|
||||||
[clojure.tools.logging :as log]
|
|
||||||
[integrant.core :as ig])
|
|
||||||
(:import
|
|
||||||
[java.net URI]
|
|
||||||
[java.net.http HttpClient WebSocket WebSocket$Listener]
|
|
||||||
[java.util.concurrent CompletableFuture CompletionStage]))
|
|
||||||
|
|
||||||
(defn- save-price! [query-fn price]
|
|
||||||
(try
|
|
||||||
(query-fn :insert-binance-price! {:price price})
|
|
||||||
(catch Exception e
|
|
||||||
(log/error e "Failed to save BTC price"))))
|
|
||||||
|
|
||||||
(defn- connect!
|
|
||||||
"Opens a WebSocket to Binance trade stream and returns the WebSocket instance.
|
|
||||||
`state` is an atom with keys :running?, :last-write, :buffer."
|
|
||||||
[uri query-fn state]
|
|
||||||
(let [client (HttpClient/newHttpClient)
|
|
||||||
listener (reify WebSocket$Listener
|
|
||||||
(onOpen [_ ws]
|
|
||||||
(log/info "Binance WebSocket connected")
|
|
||||||
(.request ws 1))
|
|
||||||
|
|
||||||
(onText [_ ws data last?]
|
|
||||||
(let [buf (:buffer @state)]
|
|
||||||
(.append buf data)
|
|
||||||
(when last?
|
|
||||||
(let [text (str buf)]
|
|
||||||
(.setLength buf 0)
|
|
||||||
(try
|
|
||||||
(let [msg (json/read-str text :key-fn keyword)
|
|
||||||
price (some-> (:p msg) bigdec)]
|
|
||||||
(when price
|
|
||||||
(let [now (System/currentTimeMillis)]
|
|
||||||
(when (> (- now (:last-write @state)) 5000)
|
|
||||||
(swap! state assoc :last-write now)
|
|
||||||
(log/info "BTC price:" (str price))
|
|
||||||
(save-price! query-fn price)))))
|
|
||||||
(catch Exception e
|
|
||||||
(log/error e "Failed to parse Binance message")))))
|
|
||||||
(let [^CompletionStage cf (CompletableFuture/completedFuture nil)]
|
|
||||||
(.request ws 1)
|
|
||||||
cf)))
|
|
||||||
|
|
||||||
(onClose [_ _ws code reason]
|
|
||||||
(log/warn "Binance WebSocket closed:" code reason)
|
|
||||||
(when (:running? @state)
|
|
||||||
(future
|
|
||||||
(Thread/sleep 3000)
|
|
||||||
(when (:running? @state)
|
|
||||||
(log/info "Reconnecting to Binance...")
|
|
||||||
(try
|
|
||||||
(let [ws (connect! uri query-fn state)]
|
|
||||||
(swap! state assoc :ws ws))
|
|
||||||
(catch Exception e
|
|
||||||
(log/error e "Binance reconnect failed")))))))
|
|
||||||
|
|
||||||
(onError [_ _ws error]
|
|
||||||
(log/error error "Binance WebSocket error")))]
|
|
||||||
(-> (.newWebSocketBuilder client)
|
|
||||||
(.buildAsync (URI. uri) listener)
|
|
||||||
(.join))))
|
|
||||||
|
|
||||||
(defmethod ig/init-key :ws/binance
|
|
||||||
[_ {:keys [query-fn uri]}]
|
|
||||||
(log/info "Starting Binance WebSocket listener:" uri)
|
|
||||||
(let [state (atom {:running? true
|
|
||||||
:last-write 0
|
|
||||||
:buffer (StringBuilder.)
|
|
||||||
:ws nil})
|
|
||||||
ws (connect! uri query-fn state)]
|
|
||||||
(swap! state assoc :ws ws)
|
|
||||||
state))
|
|
||||||
|
|
||||||
(defmethod ig/halt-key! :ws/binance
|
|
||||||
[_ state]
|
|
||||||
(log/info "Stopping Binance WebSocket listener")
|
|
||||||
(swap! state assoc :running? false)
|
|
||||||
(when-let [ws (:ws @state)]
|
|
||||||
(try
|
|
||||||
(.sendClose ws WebSocket/NORMAL_CLOSURE "shutting down")
|
|
||||||
(catch Exception _))))
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
content: ["./src/**/*.clj", "./resources/**/*.html"],
|
content: ["./app/**/*.py", "./resources/**/*.html"],
|
||||||
theme: { extend: {} },
|
theme: { extend: {} },
|
||||||
plugins: []
|
plugins: []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
(ns pmagnus.btcprice.core-test
|
|
||||||
(:require
|
|
||||||
[clojure.test :refer [deftest is]]))
|
|
||||||
|
|
||||||
(deftest app-loads-test
|
|
||||||
(is (= 1 1)))
|
|
||||||
@@ -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)))))))
|
|
||||||
Reference in New Issue
Block a user