Initial Kit project with PostgreSQL, HTMX, Hiccup, and Tailwind
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
(ns pmagnus.elprice.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,43 @@
|
||||
(ns pmagnus.elprice.core
|
||||
(:require
|
||||
[clojure.tools.logging :as log]
|
||||
[integrant.core :as ig]
|
||||
[pmagnus.elprice.config :as config]
|
||||
[pmagnus.elprice.env :refer [defaults]]
|
||||
|
||||
;; Edges
|
||||
[kit.edge.db.sql.conman]
|
||||
[kit.edge.db.sql.migratus]
|
||||
[kit.edge.db.postgres]
|
||||
[kit.edge.db.mysql]
|
||||
[kit.edge.server.undertow]
|
||||
[pmagnus.elprice.web.handler]
|
||||
|
||||
;; Routes
|
||||
[pmagnus.elprice.web.routes.api]
|
||||
[pmagnus.elprice.web.routes.ui])
|
||||
(:gen-class))
|
||||
|
||||
;; log uncaught exceptions in threads
|
||||
(Thread/setDefaultUncaughtExceptionHandler
|
||||
(fn [thread ex]
|
||||
(log/error {:what :uncaught-exception
|
||||
:exception ex
|
||||
:where (str "Uncaught exception on" (.getName thread))})))
|
||||
|
||||
(defonce system (atom nil))
|
||||
|
||||
(defn stop-app []
|
||||
((or (:stop defaults) (fn [])))
|
||||
(some-> (deref system) (ig/halt!)))
|
||||
|
||||
(defn start-app [& [params]]
|
||||
((or (:start params) (:start defaults) (fn [])))
|
||||
(->> (config/system-config (or (:opts params) (:opts defaults) {}))
|
||||
(ig/expand)
|
||||
(ig/init)
|
||||
(reset! system)))
|
||||
|
||||
(defn -main [& _]
|
||||
(start-app)
|
||||
(.addShutdownHook (Runtime/getRuntime) (Thread. (fn [] (stop-app) (shutdown-agents)))))
|
||||
@@ -0,0 +1,13 @@
|
||||
(ns pmagnus.elprice.web.controllers.health
|
||||
(:require
|
||||
[ring.util.http-response :as http-response])
|
||||
(:import
|
||||
[java.util Date]))
|
||||
|
||||
(defn healthcheck!
|
||||
[req]
|
||||
(http-response/ok
|
||||
{:time (str (Date. (System/currentTimeMillis)))
|
||||
:up-since (str (Date. (.getStartTime (java.lang.management.ManagementFactory/getRuntimeMXBean))))
|
||||
:app {:status "up"
|
||||
:message ""}}))
|
||||
@@ -0,0 +1,45 @@
|
||||
(ns pmagnus.elprice.web.handler
|
||||
(:require
|
||||
[pmagnus.elprice.web.middleware.core :as middleware]
|
||||
[integrant.core :as ig]
|
||||
[ring.util.http-response :as http-response]
|
||||
[reitit.ring :as ring]
|
||||
[reitit.swagger-ui :as swagger-ui]))
|
||||
|
||||
(defmethod ig/init-key :handler/ring
|
||||
[_ {:keys [router api-path] :as opts}]
|
||||
(ring/ring-handler
|
||||
(router)
|
||||
(ring/routes
|
||||
;; Handle trailing slash in routes - add it + redirect to it
|
||||
;; https://github.com/metosin/reitit/blob/master/doc/ring/slash_handler.md
|
||||
(ring/redirect-trailing-slash-handler)
|
||||
(ring/create-resource-handler {:path "/"})
|
||||
(when (some? api-path)
|
||||
(swagger-ui/create-swagger-ui-handler {:path api-path
|
||||
:url (str api-path "/swagger.json")}))
|
||||
(ring/create-default-handler
|
||||
{:not-found
|
||||
(constantly (-> {:status 404, :body "Page not found"}
|
||||
(http-response/content-type "text/plain")))
|
||||
:method-not-allowed
|
||||
(constantly (-> {:status 405, :body "Not allowed"}
|
||||
(http-response/content-type "text/plain")))
|
||||
:not-acceptable
|
||||
(constantly (-> {:status 406, :body "Not acceptable"}
|
||||
(http-response/content-type "text/plain")))}))
|
||||
{: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] :as opts}]
|
||||
(if (= env :dev)
|
||||
#(ring/router ["" opts routes])
|
||||
(constantly (ring/router ["" opts routes]))))
|
||||
@@ -0,0 +1,25 @@
|
||||
(ns pmagnus.elprice.web.htmx
|
||||
(:require
|
||||
[ring.util.http-response :as http-response]
|
||||
[hiccup.core :as h]
|
||||
[hiccup.page :as p]))
|
||||
|
||||
(defmacro page [opts & content]
|
||||
`(-> (p/html5 ~opts
|
||||
[:head
|
||||
[:meta {:charset "UTF-8"}]
|
||||
[:meta {:name "viewport"
|
||||
:content "width=device-width, initial-scale=1, viewport-fit=cover"}]
|
||||
[:link {:rel "stylesheet" :href "/css/output.css"}]
|
||||
[:script {:src "https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js"
|
||||
:defer true}]]
|
||||
[:body {:class "bg-gray-50 min-h-screen"}
|
||||
[:div {:class "mx-auto max-w-[390px] px-4 py-6"}
|
||||
~@content]])
|
||||
http-response/ok
|
||||
(http-response/content-type "text/html")))
|
||||
|
||||
(defmacro fragment [opts & content]
|
||||
`(-> (str (h/html ~opts ~@content))
|
||||
http-response/ok
|
||||
(http-response/content-type "text/html")))
|
||||
@@ -0,0 +1,14 @@
|
||||
(ns pmagnus.elprice.web.middleware.core
|
||||
(:require
|
||||
[pmagnus.elprice.env :as env]
|
||||
[ring.middleware.defaults :as defaults]
|
||||
[ring.middleware.session.cookie :as cookie]))
|
||||
|
||||
(defn wrap-base
|
||||
[{:keys [metrics site-defaults-config cookie-secret] :as opts}]
|
||||
(let [cookie-store (cookie/cookie-store {:key (.getBytes ^String cookie-secret)})]
|
||||
(fn [handler]
|
||||
(cond-> ((:middleware env/defaults) handler opts)
|
||||
true (defaults/wrap-defaults
|
||||
(assoc-in site-defaults-config [:session :store] cookie-store))
|
||||
))))
|
||||
@@ -0,0 +1,31 @@
|
||||
(ns pmagnus.elprice.web.middleware.exception
|
||||
(:require
|
||||
[clojure.tools.logging :as log]
|
||||
[reitit.ring.middleware.exception :as exception]))
|
||||
|
||||
(defn handler [message status exception request]
|
||||
(when (>= status 500)
|
||||
;; You can optionally use this to report error to an external service
|
||||
(log/error exception))
|
||||
{:status status
|
||||
:body {:message message
|
||||
:exception (.getClass exception)
|
||||
:data (ex-data exception)
|
||||
:uri (:uri request)}})
|
||||
|
||||
(def wrap-exception
|
||||
(exception/create-exception-middleware
|
||||
(merge
|
||||
exception/default-handlers
|
||||
{:system.exception/internal (partial handler "internal exception" 500)
|
||||
:system.exception/business (partial handler "bad request" 400)
|
||||
:system.exception/not-found (partial handler "not found" 404)
|
||||
:system.exception/unauthorized (partial handler "unauthorized" 401)
|
||||
:system.exception/forbidden (partial handler "forbidden" 403)
|
||||
|
||||
;; override the default handler
|
||||
::exception/default (partial handler "default" 500)
|
||||
|
||||
;; print stack-traces for all exceptions
|
||||
::exception/wrap (fn [handler e request]
|
||||
(handler e request))})))
|
||||
@@ -0,0 +1,14 @@
|
||||
(ns pmagnus.elprice.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,52 @@
|
||||
(ns pmagnus.elprice.web.routes.api
|
||||
(:require
|
||||
[pmagnus.elprice.web.controllers.health :as health]
|
||||
[pmagnus.elprice.web.middleware.exception :as exception]
|
||||
[pmagnus.elprice.web.middleware.formats :as formats]
|
||||
[integrant.core :as ig]
|
||||
[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]))
|
||||
|
||||
(def route-data
|
||||
{:coercion malli/coercion
|
||||
:muuntaja formats/instance
|
||||
:swagger {:id ::api}
|
||||
:middleware [;; query-params & form-params
|
||||
parameters/parameters-middleware
|
||||
;; content-negotiation
|
||||
muuntaja/format-negotiate-middleware
|
||||
;; encoding response body
|
||||
muuntaja/format-response-middleware
|
||||
;; exception handling
|
||||
coercion/coerce-exceptions-middleware
|
||||
;; decoding request body
|
||||
muuntaja/format-request-middleware
|
||||
;; coercing response bodys
|
||||
coercion/coerce-response-middleware
|
||||
;; coercing request parameters
|
||||
coercion/coerce-request-middleware
|
||||
;; exception handling
|
||||
exception/wrap-exception]})
|
||||
|
||||
;; Routes
|
||||
(defn api-routes [_opts]
|
||||
[["/swagger.json"
|
||||
{:get {:no-doc true
|
||||
:swagger {:info {:title "pmagnus.elprice API"}}
|
||||
:handler (swagger/create-swagger-handler)}}]
|
||||
["/health"
|
||||
;; note that use of the var is necessary
|
||||
;; for reitit to reload routes without
|
||||
;; restarting the system
|
||||
{:get #'health/healthcheck!}]])
|
||||
|
||||
(derive :reitit.routes/api :reitit/routes)
|
||||
|
||||
(defmethod ig/init-key :reitit.routes/api
|
||||
[_ {:keys [base-path]
|
||||
:or {base-path ""}
|
||||
:as opts}]
|
||||
(fn [] [base-path route-data (api-routes opts)]))
|
||||
@@ -0,0 +1,50 @@
|
||||
(ns pmagnus.elprice.web.routes.ui
|
||||
(:require
|
||||
[pmagnus.elprice.web.middleware.exception :as exception]
|
||||
[pmagnus.elprice.web.middleware.formats :as formats]
|
||||
[pmagnus.elprice.web.routes.utils :as utils]
|
||||
[pmagnus.elprice.web.htmx :refer [page fragment] :as htmx]
|
||||
[integrant.core :as ig]
|
||||
[reitit.ring.middleware.muuntaja :as muuntaja]
|
||||
[reitit.ring.middleware.parameters :as parameters]))
|
||||
|
||||
(defn home [request]
|
||||
(page {:lang "en"}
|
||||
[:h1 {:class "text-2xl font-bold text-gray-900"}
|
||||
"Welcome to elprice"]
|
||||
[:p {:class "mt-2 text-gray-600"}
|
||||
"Built with Kit, HTMX, Hiccup & Tailwind"]
|
||||
[:button {:class "mt-4 rounded-lg bg-blue-600 px-4 py-2 text-white font-medium
|
||||
active:bg-blue-700 transition-colors"
|
||||
:hx-post "/clicked"
|
||||
:hx-swap "outerHTML"}
|
||||
"Click me!"]))
|
||||
|
||||
(defn clicked [request]
|
||||
(fragment
|
||||
[:div {:class "mt-4 rounded-lg bg-green-100 p-4 text-green-800"}
|
||||
"Congratulations! You just clicked the button!"]))
|
||||
|
||||
;; Routes
|
||||
(defn ui-routes [_opts]
|
||||
[["/" {:get home}]
|
||||
["/clicked" {:post clicked}]])
|
||||
|
||||
(def route-data
|
||||
{:muuntaja formats/instance
|
||||
:middleware
|
||||
[;; Default middleware for ui
|
||||
;; query-params & form-params
|
||||
parameters/parameters-middleware
|
||||
;; encoding response body
|
||||
muuntaja/format-response-middleware
|
||||
;; exception handling
|
||||
exception/wrap-exception]})
|
||||
|
||||
(derive :reitit.routes/ui :reitit/routes)
|
||||
|
||||
(defmethod ig/init-key :reitit.routes/ui
|
||||
[_ {:keys [base-path]
|
||||
:or {base-path ""}
|
||||
:as opts}]
|
||||
(fn [] [base-path route-data (ui-routes opts)]))
|
||||
@@ -0,0 +1,11 @@
|
||||
(ns pmagnus.elprice.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-in req (conj route-data-path k)))
|
||||
Reference in New Issue
Block a user