Initial project setup: Clojure Kit web app for BTC price tracking
Kit framework with Undertow, Reitit routing, Hiccup + HTMX templating, Tailwind CSS v4, PostgreSQL (empty database), and mobile-first index page. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
|||||||
|
/target
|
||||||
|
/classes
|
||||||
|
/checkouts
|
||||||
|
profiles.clj
|
||||||
|
pom.xml
|
||||||
|
pom.xml.asc
|
||||||
|
*.jar
|
||||||
|
*.class
|
||||||
|
/.lein-*
|
||||||
|
/.nrepl-port
|
||||||
|
/.cpcache
|
||||||
|
/.clj-kondo
|
||||||
|
/node_modules
|
||||||
|
/log
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
/.env
|
||||||
|
/token
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# btcprice
|
||||||
|
|
||||||
|
Bitcoin price tracker - Clojure Kit web application.
|
||||||
|
|
||||||
|
## Build & Development Commands
|
||||||
|
|
||||||
|
- `make run` — Start dev server on port 3000
|
||||||
|
- `make repl` — Start nREPL on port 7888
|
||||||
|
- `make test` — Run tests
|
||||||
|
- `make tailwind` — Watch Tailwind CSS for changes
|
||||||
|
- `make uberjar` — Build production JAR
|
||||||
|
- `docker compose up -d` — Start PostgreSQL
|
||||||
|
|
||||||
|
## REPL Commands
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(dev-prep!) ;; Prepare dev system
|
||||||
|
(go) ;; Start system
|
||||||
|
(reset) ;; Reload code & restart
|
||||||
|
(halt) ;; Stop system
|
||||||
|
(reset-db) ;; Drop & re-migrate database
|
||||||
|
(migrate) ;; Run pending migrations
|
||||||
|
(rollback) ;; Rollback last migration
|
||||||
|
(query-fn) ;; Get database query function
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Framework:** Kit (Integrant-based)
|
||||||
|
- **Server:** Undertow
|
||||||
|
- **Routing:** Reitit
|
||||||
|
- **Templates:** Hiccup + HTMX
|
||||||
|
- **Styling:** Tailwind CSS v4
|
||||||
|
- **Database:** PostgreSQL + conman + Migratus
|
||||||
|
|
||||||
|
## Source Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/clj/pmagnus/btcprice/
|
||||||
|
├── core.clj # App entry point
|
||||||
|
├── 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
|
||||||
|
```
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
FROM clojure:openjdk-25 AS build
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
COPY . .
|
||||||
|
RUN clj -Sforce -T:build all
|
||||||
|
|
||||||
|
FROM azul/zulu-openjdk-alpine:25
|
||||||
|
|
||||||
|
COPY --from=build /build/target/btcprice-standalone.jar /btcprice/btcprice-standalone.jar
|
||||||
|
|
||||||
|
EXPOSE 4040
|
||||||
|
|
||||||
|
CMD ["java", "-jar", "/btcprice/btcprice-standalone.jar"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
.PHONY: clean run repl tailwind test uberjar
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf target
|
||||||
|
|
||||||
|
run:
|
||||||
|
clj -M:dev -e "(dev-prep!) (go)" -r
|
||||||
|
|
||||||
|
repl:
|
||||||
|
clj -M:dev:nrepl
|
||||||
|
|
||||||
|
tailwind:
|
||||||
|
npm run css:watch
|
||||||
|
|
||||||
|
test:
|
||||||
|
clj -M:test
|
||||||
|
|
||||||
|
uberjar:
|
||||||
|
clj -T:build all
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{: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")}}}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
(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))
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{: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.4.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,4 @@
|
|||||||
|
(ns pmagnus.btcprice.dev-middleware)
|
||||||
|
|
||||||
|
(defn wrap-dev [handler _opts]
|
||||||
|
handler)
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
(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
+48
@@ -0,0 +1,48 @@
|
|||||||
|
(ns user
|
||||||
|
(:require
|
||||||
|
[clojure.tools.logging :as log]
|
||||||
|
[integrant.core :as ig]
|
||||||
|
[integrant.repl :refer [go halt reset reset-all set-prep!]]
|
||||||
|
[pmagnus.btcprice.config :as config]))
|
||||||
|
|
||||||
|
(defn dev-prep! []
|
||||||
|
(set-prep!
|
||||||
|
(fn []
|
||||||
|
(-> (config/system-config {:profile :dev})
|
||||||
|
(ig/prep)))))
|
||||||
|
|
||||||
|
(defn test-prep! []
|
||||||
|
(set-prep!
|
||||||
|
(fn []
|
||||||
|
(-> (config/system-config {:profile :test})
|
||||||
|
(ig/prep)))))
|
||||||
|
|
||||||
|
(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
@@ -0,0 +1,15 @@
|
|||||||
|
<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
@@ -0,0 +1,10 @@
|
|||||||
|
(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
@@ -0,0 +1,14 @@
|
|||||||
|
<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
@@ -0,0 +1,11 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{: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"}]}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
Generated
+1054
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "btcprice",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"css:build": "npx @tailwindcss/cli -i resources/css/input.css -o resources/public/css/output.css --minify",
|
||||||
|
"css:watch": "npx @tailwindcss/cli -i resources/css/input.css -o resources/public/css/output.css --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/cli": "^4.1.18",
|
||||||
|
"tailwindcss": "^4.1.18"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@source "../../src/**/*.clj";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Migration files go here.
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
|||||||
|
-- queries for btcprice
|
||||||
|
-- Add HugSQL queries here as tables are created
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{: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
|
||||||
|
#profile {:dev {:jdbc-url "jdbc:postgresql://localhost:5432/btcprice?user=btcprice&password=btcprice"}
|
||||||
|
:test {:jdbc-url "jdbc:postgresql://localhost:5432/btcprice?user=btcprice&password=btcprice"}
|
||||||
|
:prod {:jdbc-url #env JDBC_URL
|
||||||
|
:init-size 1
|
||||||
|
:min-idle 1
|
||||||
|
:max-idle 8
|
||||||
|
:max-active 32}}
|
||||||
|
|
||||||
|
: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}}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
(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))
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
(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])
|
||||||
|
(: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/prep)
|
||||||
|
(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)))
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
(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"}}))
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
(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))))
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
(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))}))
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
(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)))))
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
(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))})))
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
(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)))))
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
(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)])
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
(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 [_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
|
||||||
|
[:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6
|
||||||
|
[:p.text-center.text-gray-400.text-sm "Loading..."]]]))
|
||||||
|
|
||||||
|
(defn- ui-routes [_opts]
|
||||||
|
[["/"
|
||||||
|
{:get home-page}]])
|
||||||
|
|
||||||
|
(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)])
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
(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))
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
content: ["./src/**/*.clj", "./resources/**/*.html"],
|
||||||
|
theme: { extend: {} },
|
||||||
|
plugins: []
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
(ns pmagnus.btcprice.core-test
|
||||||
|
(:require
|
||||||
|
[clojure.test :refer [deftest is]]))
|
||||||
|
|
||||||
|
(deftest app-loads-test
|
||||||
|
(is (= 1 1)))
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
(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))))))
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
(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