mirror of
https://github.com/penpot/penpot.git
synced 2026-08-29 16:18:48 +00:00
✨ 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:
parent
feda8ac9f2
commit
2332be1e2c
@ -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)}))))
|
||||||
|
|
||||||
|
|||||||
@ -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))))))
|
||||||
|
|
||||||
|
|||||||
78
backend/test/backend_tests/http_websocket_test.clj
Normal file
78
backend/test/backend_tests/http_websocket_test.clj
Normal 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)))))
|
||||||
Loading…
x
Reference in New Issue
Block a user