init commit

This commit is contained in:
2026-02-17 00:23:25 -05:00
commit 79b6a5e225
25 changed files with 1648 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
{:paths ["src"]
:deps {org.clojure/clojure {:mvn/version "1.12.0"}
com.github.seancorfield/next.jdbc {:mvn/version "1.3.955"}
com.github.seancorfield/honeysql {:mvn/version "2.6.1230"}
org.postgresql/postgresql {:mvn/version "42.7.4"}
io.nats/jnats {:mvn/version "2.20.5"}
org.babashka/http-client {:mvn/version "0.4.22"}
org.clojure/data.json {:mvn/version "2.5.1"}}}
+218
View File
@@ -0,0 +1,218 @@
(ns ajet.chat.shared.api-client
"HTTP client SDK for the ajet-chat API.
All public functions take an explicit context map (ctx) as the first argument.
This avoids dynamic vars which don't work with core.async or cross-thread
callbacks (NATS handlers, SSE).
ctx shape:
{:base-url \"http://localhost:3001\" ;; API URL (SMs) or Auth GW URL (CLI)
:auth-token \"base64url-token\" ;; raw token, SDK prepends \"Bearer \"
:trace-id \"uuid\" ;; optional, from X-Trace-Id
:user-id \"uuid\" ;; optional, informational
:user-role \"admin\"} ;; optional, informational
Error handling:
- HTTP 4xx/5xx → ex-info with {:type :ajet.chat/api-error, :status, :body, :trace-id}
- Network errors (connect refused, timeout) → propagate raw from http-client"
(:require [babashka.http-client :as http]
[clojure.data.json :as json]
[clojure.string :as str]))
;;; ---------------------------------------------------------------------------
;;; Private HTTP layer
;;; ---------------------------------------------------------------------------
(defn- api-url
"Join base-url and path, ensuring exactly one slash between them."
[base-url path]
(let [base (if (str/ends-with? base-url "/")
(subs base-url 0 (dec (count base-url)))
base-url)
p (if (str/starts-with? path "/")
(subs path 1)
path)]
(str base "/" p)))
(defn- build-headers
"Build HTTP headers from ctx."
[ctx]
(cond-> {"Accept" "application/json"}
(:auth-token ctx) (assoc "Authorization" (str "Bearer " (:auth-token ctx)))
(:trace-id ctx) (assoc "X-Trace-Id" (:trace-id ctx))))
(defn- encode-json
"Encode a Clojure value as a JSON string."
[data]
(json/write-str data))
(defn- parse-json
"Parse a JSON string into a Clojure map with keyword keys.
Returns nil for nil/blank input."
[s]
(when-not (str/blank? s)
(json/read-str s :key-fn keyword)))
(defn- check-response!
"Throw ex-info on 4xx/5xx responses."
[response trace-id]
(let [status (:status response)]
(when (>= status 400)
(throw (ex-info (str "API error: HTTP " status)
{:type :ajet.chat/api-error
:status status
:body (parse-json (:body response))
:trace-id trace-id})))))
(defn- request!
"Core HTTP dispatch. All public functions route through here.
Returns parsed JSON body as a Clojure map."
[ctx method path & [{:keys [body query-params]}]]
(let [headers (cond-> (build-headers ctx)
body (assoc "Content-Type" "application/json"))
url (api-url (:base-url ctx) path)
trace-id (:trace-id ctx)
opts (cond-> {:method method
:uri url
:headers headers
:throw false}
body (assoc :body (encode-json body))
query-params (assoc :query-params query-params))
response (http/request opts)]
(check-response! response trace-id)
(parse-json (:body response))))
;;; ---------------------------------------------------------------------------
;;; Context helper
;;; ---------------------------------------------------------------------------
(defn request->ctx
"Build an API client ctx from a Ring request and the API base URL.
Extracts auth token from Authorization header and trace/user info from
custom headers injected by Auth GW."
[ring-request api-base-url]
(let [headers (:headers ring-request)
auth (get headers "authorization")
token (when auth
(let [parts (str/split auth #"\s+" 2)]
(when (= "Bearer" (first parts))
(second parts))))]
(cond-> {:base-url api-base-url}
token (assoc :auth-token token)
(get headers "x-trace-id") (assoc :trace-id (get headers "x-trace-id"))
(get headers "x-user-id") (assoc :user-id (get headers "x-user-id"))
(get headers "x-user-role") (assoc :user-role (get headers "x-user-role")))))
;;; ---------------------------------------------------------------------------
;;; Public API — Channels (community-scoped)
;;; ---------------------------------------------------------------------------
(defn get-channels
"List channels for a community."
[ctx community-slug]
(request! ctx :get (str "c/" community-slug "/channels")))
(defn get-channel
"Get a single channel by ID within a community."
[ctx community-slug channel-id]
(request! ctx :get (str "c/" community-slug "/channels/" channel-id)))
;;; ---------------------------------------------------------------------------
;;; Public API — Messages
;;; ---------------------------------------------------------------------------
(defn get-messages
"Fetch messages for a channel. opts may include :after, :before, :limit, :thread."
[ctx channel-id & [opts]]
(let [qp (cond-> {}
(:after opts) (assoc "after" (str (:after opts)))
(:before opts) (assoc "before" (str (:before opts)))
(:limit opts) (assoc "limit" (str (:limit opts)))
(:thread opts) (assoc "thread" (str (:thread opts))))]
(request! ctx :get (str "api/messages/" channel-id)
(when (seq qp) {:query-params qp}))))
(defn send-message
"Send a message to a channel. body-map should contain at least :body_md."
[ctx channel-id body-map]
(request! ctx :post (str "api/messages/" channel-id) {:body body-map}))
(defn edit-message
"Edit a message. body-map should contain :body_md."
[ctx message-id body-map]
(request! ctx :put (str "api/messages/" message-id) {:body body-map}))
(defn delete-message
"Delete a message."
[ctx message-id]
(request! ctx :delete (str "api/messages/" message-id)))
;;; ---------------------------------------------------------------------------
;;; Public API — Reactions
;;; ---------------------------------------------------------------------------
(defn add-reaction
"Add a reaction (emoji) to a message. Idempotent."
[ctx message-id emoji]
(request! ctx :put (str "api/messages/" message-id "/reactions/" emoji)))
(defn remove-reaction
"Remove a reaction (emoji) from a message."
[ctx message-id emoji]
(request! ctx :delete (str "api/messages/" message-id "/reactions/" emoji)))
;;; ---------------------------------------------------------------------------
;;; Public API — DMs (global)
;;; ---------------------------------------------------------------------------
(defn get-dms
"List the current user's DM channels."
[ctx]
(request! ctx :get "dm"))
(defn get-or-create-dm
"Get or create a DM channel with another user. body-map should contain :user_id."
[ctx body-map]
(request! ctx :post "dm" {:body body-map}))
;;; ---------------------------------------------------------------------------
;;; Public API — Users
;;; ---------------------------------------------------------------------------
(defn get-user
"Get a user by ID."
[ctx user-id]
(request! ctx :get (str "api/users/" user-id)))
(defn get-me
"Get the current authenticated user."
[ctx]
(request! ctx :get "api/users/me"))
;;; ---------------------------------------------------------------------------
;;; Public API — Notifications
;;; ---------------------------------------------------------------------------
(defn get-notifications
"Fetch notifications. opts may include :unread, :after, :limit."
[ctx & [opts]]
(let [qp (cond-> {}
(some? (:unread opts)) (assoc "unread" (str (:unread opts)))
(:after opts) (assoc "after" (str (:after opts)))
(:limit opts) (assoc "limit" (str (:limit opts))))]
(request! ctx :get "api/notifications"
(when (seq qp) {:query-params qp}))))
(defn mark-notifications-read
"Mark notifications as read. body-map should contain :notification_ids."
[ctx body-map]
(request! ctx :post "api/notifications/read" {:body body-map}))
;;; ---------------------------------------------------------------------------
;;; Public API — Presence
;;; ---------------------------------------------------------------------------
(defn heartbeat
"Send a presence heartbeat."
[ctx]
(request! ctx :post "api/heartbeat"))
+17
View File
@@ -0,0 +1,17 @@
(ns ajet.chat.shared.db
"Database layer — uses next.jdbc + HoneySQL. PostgreSQL everywhere."
(:require [next.jdbc :as jdbc]
[honey.sql :as sql]))
(defn make-datasource
"Create a PostgreSQL datasource."
[& [{:keys [dbname host port user password]
:or {dbname "ajet_chat"
host "localhost"
port 5432}}]]
(jdbc/get-datasource {:dbtype "postgresql"
:dbname dbname
:host host
:port port
:user user
:password password}))