Negotiate transit/JSON payload format on WebSocket

Extend the transit/JSON content negotiation to the WebSocket
notifications endpoint (/ws/notifications). The format is
negotiated once on the upgrade request (Accept header or
_fmt=json query parameter, same rules as the rest of the RPC
API) and applies bidirectionally: outbound notification
messages are encoded with the negotiated encoder and inbound
client messages are decoded accordingly.

The generic websocket protocol implementation now accepts
encode/decode functions as listener options, defaulting to
the historical transit encoding. A small normalization step
converts JSON decoded messages (:type to keyword and entity
ids to UUID objects) into the same shape produced by transit.

Closes #11409

AI-assisted-by: glm-5.3-flash
This commit is contained in:
Andrey Antukh 2026-08-28 09:39:27 +00:00
parent feda8ac9f2
commit 2332be1e2c
3 changed files with 139 additions and 8 deletions

View File

@ -9,12 +9,15 @@
(:require (:require
[app.binfile.common :as bfc] [app.binfile.common :as bfc]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.json :as json]
[app.common.logging :as l] [app.common.logging :as l]
[app.common.pprint :as pp] [app.common.pprint :as pp]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct] [app.common.time :as ct]
[app.common.transit :as t]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.db :as db] [app.db :as db]
[app.http.content-negotiation :as cnegot]
[app.http.session :as session] [app.http.session :as session]
[app.metrics :as mtx] [app.metrics :as mtx]
[app.msgbus :as mbus] [app.msgbus :as mbus]
@ -84,6 +87,39 @@
;; WEBSOCKET HANDLER ;; WEBSOCKET HANDLER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn resolve-encoder
"Resolve the outbound message encoder for the negotiated format.
The transit encoding is the default for unexpected format values."
[format]
(case format
:transit #(t/encode-str % {:type :json-verbose})
:json cnegot/json-encode-str
#(t/encode-str % {:type :json-verbose})))
(defn resolve-decoder
"Resolve the inbound message decoder for the negotiated format.
The transit decoding is the default for unexpected format values."
[format]
(case format
:transit t/decode-str
:json #(json/decode % {:key-fn json/read-kebab-key})
t/decode-str))
(defn normalize-message
"Normalize the inbound message into the same shape produced by the
transit decoding: the `:type` value is a keyword and the entity ids
are UUID objects. This is a no-op for transit decoded messages."
[message]
(cond-> message
(string? (:type message))
(assoc :type (keyword (:type message)))
(string? (:team-id message))
(assoc :team-id (uuid/parse* (:team-id message)))
(string? (:file-id message))
(assoc :file-id (uuid/parse* (:file-id message)))))
(defmulti handle-message (defmulti handle-message
(fn [_ _ message] (fn [_ _ message]
(:type message))) (:type message)))
@ -267,7 +303,8 @@
:id :websocket-messages-total :id :websocket-messages-total
:labels recv-labels :labels recv-labels
:inc 1) :inc 1)
(assoc message :profile-id profile-id :session-id session-id)) (let [message (normalize-message message)]
(assoc message :profile-id profile-id :session-id session-id)))
(defn- on-snd-message (defn- on-snd-message
[{:keys [::mtx/metrics]} message] [{:keys [::mtx/metrics]} message]
@ -279,7 +316,8 @@
(defn- http-handler (defn- http-handler
[cfg {:keys [params ::session/profile-id] :as request}] [cfg {:keys [params ::session/profile-id] :as request}]
(let [session-id (some-> params :session-id uuid/parse*)] (let [session-id (some-> params :session-id uuid/parse*)
format (cnegot/negotiate-format request)]
(when-not (uuid? session-id) (when-not (uuid? session-id)
(ex/raise :type :validation (ex/raise :type :validation
:code :missing-session-id :code :missing-session-id
@ -301,12 +339,17 @@
:else :else
(do (do
(l/trace :hint "websocket request" :profile-id profile-id :session-id session-id) (l/trace :hint "websocket request"
:profile-id profile-id
:session-id session-id
:format format)
{::yws/listener (ws/listener request {::yws/listener (ws/listener request
::ws/on-rcv-message (partial on-rcv-message cfg) ::ws/on-rcv-message (partial on-rcv-message cfg)
::ws/on-snd-message (partial on-snd-message cfg) ::ws/on-snd-message (partial on-snd-message cfg)
::ws/on-connect (partial on-connect cfg) ::ws/on-connect (partial on-connect cfg)
::ws/handler (partial handle-message cfg) ::ws/handler (partial handle-message cfg)
::ws/encode-fn (resolve-encoder format)
::ws/decode-fn (resolve-decoder format)
::profile-id profile-id ::profile-id profile-id
::session-id session-id)})))) ::session-id session-id)}))))

View File

@ -26,6 +26,9 @@
(def max-missed-heartbeats 3) (def max-missed-heartbeats 3)
(def heartbeat-interval 5000) (def heartbeat-interval 5000)
(def default-encode-fn #(t/encode-str % {:type :json-verbose}))
(def default-decode-fn t/decode-str)
(defn- encode-beat (defn- encode-beat
[n] [n]
(doto (ByteBuffer/allocate 8) (doto (ByteBuffer/allocate 8)
@ -64,6 +67,8 @@
[request & {:keys [::on-rcv-message [request & {:keys [::on-rcv-message
::on-snd-message ::on-snd-message
::on-connect ::on-connect
::encode-fn
::decode-fn
::input-buff-size ::input-buff-size
::output-buff-size ::output-buff-size
::idle-timeout] ::idle-timeout]
@ -72,7 +77,9 @@
idle-timeout 60000 idle-timeout 60000
on-connect identity on-connect identity
on-snd-message identity-3 on-snd-message identity-3
on-rcv-message identity-3} on-rcv-message identity-3
encode-fn default-encode-fn
decode-fn default-decode-fn}
:as options}] :as options}]
(assert (fn? on-rcv-message) "'on-rcv-message' should be a function") (assert (fn? on-rcv-message) "'on-rcv-message' should be a function")
@ -99,7 +106,9 @@
(assoc ::output-ch output-ch) (assoc ::output-ch output-ch)
(assoc ::close-ch close-ch) (assoc ::close-ch close-ch)
(assoc ::remote-addr ip-addr) (assoc ::remote-addr ip-addr)
(assoc ::user-agent uagent))] (assoc ::user-agent uagent)
(assoc ::encode-fn encode-fn)
(assoc ::decode-fn decode-fn))]
{:on-open {:on-open
(fn on-open [channel] (fn on-open [channel]
@ -143,7 +152,8 @@
(defn- start-io-loop! (defn- start-io-loop!
[{:keys [::id ::close-ch ::input-ch ::output-ch ::heartbeat-ch [{:keys [::id ::close-ch ::input-ch ::output-ch ::heartbeat-ch
::channel ::handler ::beats ::on-rcv-message ::on-snd-message] ::channel ::handler ::beats ::on-rcv-message ::on-snd-message
::encode-fn ::decode-fn]
:as wsp}] :as wsp}]
(try (try
(handler wsp {:type :open}) (handler wsp {:type :open})
@ -169,7 +179,7 @@
(recur i)) (recur i))
(identical? p input-ch) (identical? p input-ch)
(let [message (t/decode-str msg) (let [message (decode-fn msg)
message (on-rcv-message message) message (on-rcv-message message)
{:keys [request-id] :as response} (handler wsp message)] {:keys [request-id] :as response} (handler wsp message)]
(when (map? response) (when (map? response)
@ -181,7 +191,7 @@
(identical? p output-ch) (identical? p output-ch)
(let [message (on-snd-message msg) (let [message (on-snd-message msg)
message (t/encode-str message {:type :json-verbose})] message (encode-fn message)]
(yws/send channel message) (yws/send channel message)
(recur i)))))) (recur i))))))

View File

@ -0,0 +1,78 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.http-websocket-test
(:require
[app.common.json :as json]
[app.common.transit :as tr]
[app.common.uuid :as uuid]
[app.http.websocket :as http.ws]
[clojure.test :as t]))
(t/deftest resolve-encoder-defaults-to-transit
(let [encode (http.ws/resolve-encoder :transit)
msg {:type :join-file
:file-id (uuid/next)
:profile-id (uuid/next)}]
(t/is (= msg (tr/decode-str (encode msg))))))
(t/deftest resolve-encoder-unexpected-format-falls-back-to-transit
(let [encode (http.ws/resolve-encoder :foo)
msg {:type :join-file :file-id (uuid/next)}]
(t/is (= msg (tr/decode-str (encode msg))))))
(t/deftest resolve-encoder-json
(let [encode (http.ws/resolve-encoder :json)
file-id (uuid/next)
encoded (encode {:type :join-file :file-id file-id})]
(t/is (string? encoded))
(t/is (= "join-file" (get (json/decode encoded) "type")))
(t/is (= (str file-id) (get (json/decode encoded) "fileId")))))
(t/deftest resolve-decoder-defaults-to-transit
(let [decode (http.ws/resolve-decoder :transit)
msg {:type :subscribe-file :file-id (uuid/next)}]
(t/is (= msg (decode (tr/encode-str msg {:type :json-verbose}))))))
(t/deftest resolve-decoder-unexpected-format-falls-back-to-transit
(let [decode (http.ws/resolve-decoder :foo)
msg {:type :subscribe-file :file-id (uuid/next)}]
(t/is (= msg (decode (tr/encode-str msg {:type :json-verbose}))))))
(t/deftest resolve-decoder-json
(let [decode (http.ws/resolve-decoder :json)
file-id (uuid/next)
decoded (decode (str "{\"type\":\"subscribe-file\","
"\"fileId\":\"" file-id "\"}"))]
(t/is (= "subscribe-file" (:type decoded)))
(t/is (= (str file-id) (:file-id decoded)))))
(t/deftest normalize-message-from-json-decoding
(let [file-id (uuid/next)
decoded {:type "subscribe-file"
:file-id (str file-id)}
message (http.ws/normalize-message decoded)]
(t/is (= :subscribe-file (:type message)))
(t/is (uuid? (:file-id message)))
(t/is (= file-id (:file-id message)))))
(t/deftest normalize-message-noop-for-transit-decoding
(let [file-id (uuid/next)
decoded {:type :subscribe-file :file-id file-id}]
(t/is (= decoded (http.ws/normalize-message decoded)))))
(t/deftest normalize-message-with-invalid-uuid
(let [message (http.ws/normalize-message
{:type "subscribe-file" :file-id "invalid"})]
(t/is (= :subscribe-file (:type message)))
(t/is (nil? (:file-id message)))))
(t/deftest normalize-message-team-id
(let [team-id (uuid/next)
message (http.ws/normalize-message
{:type "subscribe-team" :team-id (str team-id)})]
(t/is (= :subscribe-team (:type message)))
(t/is (= team-id (:team-id message)))))