diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000000000000000000000000000000000..157aff81226c8e6dda48d77ec5707d232cf7b318 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +## 0.3.0 / UNRELEASED + +### Breaking + +* Formats: `PayloadFormat` moved to `otarta.format` (was `otarta.payload-format`) +* Formats: Implement `-read` and `-write` (was `read` and `write`) \ No newline at end of file diff --git a/README.md b/README.md index 04c13ff05f30aa7f6a02da2368e4c2000857b207..a3b48600640caec71d6ee756f16794935cd8c31f 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,160 @@ # Otarta +[![pipeline status](https://gitlab.com/eval/otarta/badges/master/pipeline.svg)](https://gitlab.com/eval/otarta/commits/master) +[![Clojars Project](https://img.shields.io/clojars/v/eval/otarta.svg)](https://clojars.org/eval/otarta) + + An MQTT-library for ClojureScript. -_NOTE: this is pre-alpha software with an API that will change_ +_NOTE: this is pre-alpha software with an API that will change (see the [CHANGELOG](./CHANGELOG.md) for breaking changes)_ +## Installation -## CLI +Leiningen: +```clojure +[eval/otarta "0.3.0"] +``` -The CLI allows you to subscribe from the commandline. +Deps: +```clojure +eval/otarta {:mvn/version "0.3.0"} +``` -### start local broker +## Examples -(Skip this step if you already have a broker with websocket access.) +* [CI-Dashboard](https://eval.gitlab.io/ci-dashboard/) (source: https://gitlab.com/eval/ci-dashboard) -```bash -$ docker run --rm -ti -p 9001:9001 toke/mosquitto +## Usage + +The following code assumes: +- being in a browser (ie `js/WebSockets` exists. For Node.js [see below](README.md#nodejs).) +- a websocket-enabled MQTT-broker on `localhost:9001` (eg via `docker run --rm -ti -p 9001:9001 toke/mosquitto`) + +```clojure +(ns example.core + (:require-macros [cljs.core.async.macros :refer [go go-loop]]) + (:require [cljs.core.async :as a :refer [> buff (mqtt-fmt/-read mqtt-fmt/string) extract-temperature)) + (-write [_fmt v] + (->> v (mqtt-fmt/-write mqtt-fmt/string))))) +``` + +### Node.js + +You should provide a W3C compatible websocket when running via Node.js. +I've had good experience with [this websocket-library (>= v1.0.28)](https://www.npmjs.com/package/websocket). + +With the library included in your project (see https://clojurescript.org/guides/webpack for details), the following will initialize `js/WebSocket`: + +```clojure +(ns example.core + (:require [websocket])) + +(set! js/WebSocket (.-w3cwebsocket websocket)) +``` + +## Limitations + +- only QoS 0 +- only clean-session +- no reconnect built-in +- untested for large payloads (ie more than a couple of KB) + +## Development + +### Testing + +Via [cljs-test-runner](https://github.com/Olical/cljs-test-runner/): ```bash # once @@ -42,6 +162,13 @@ $ clojure -Atest # watching $ clojure -Atest-watch + +# specific tests +(deftest ^{:focus true} only-this-test ...) +$ clojure -Atest-watch -i :focus + +# more options: +$ clojure -Atest-watch --help ``` ### Figwheel @@ -59,6 +186,9 @@ $ node target/app.js user> (figwheel/cljs-repl) ;; prompt changes to: cljs.user> +;; to quickly see what otarta can do: +;; - evaluate the otarta.main namespace +;; - then eval the comment-section of otarta.main line by line ``` See [CIDER docs](https://cider.readthedocs.io/en/latest/interactive_programming/) what you can do. @@ -83,6 +213,8 @@ See [CIDER docs](https://cider.readthedocs.io/en/latest/interactive_programming/ ## License -Copyright (c) 2018 Alliander N.V. See [LICENSE](./LICENSE). +Copyright (c) 2018 Gert Goet, ThinkCreate +Copyright (c) 2018 Alliander N.V. +See [LICENSE](./LICENSE). For licenses of third-party software that this software uses, see [LICENSE-3RD-PARTY](./LICENSE-3RD-PARTY). diff --git a/src/otarta/core.cljs b/src/otarta/core.cljs index e113aee65f006ef8abcb53a3620cc5c4b4ddc6c2..d37c329533bd17bdfd062b33ea8563bb1fac156f 100644 --- a/src/otarta/core.cljs +++ b/src/otarta/core.cljs @@ -8,11 +8,30 @@ [haslett.format :as ws-fmt] [huon.log :refer [debug info warn error]] [lambdaisland.uri :as uri] - [otarta.payload-format :as payload-fmt :refer [PayloadFormat]] + [otarta.format :as fmt :refer [PayloadFormat]] [otarta.packet :as packet] [otarta.util :as util :refer-macros [ err-> err->>]])) +(extend-type js/Uint8Array + ICounted + (-count [uia] + (.-length uia))) + + +(defn- empty-payload? + "Examples: + ;; when receiving: + (empty-payload? (js/Uint8Array.) ;; => true + + ;; when sending: + (empty-payload? nil) ;; => true + (empty-payload? \"\") ;; => true + (empty-payload? \" \") ;; => false" + [pl] + (zero? (count pl))) + + (def mqtt-format "Read and write mqtt-packets" (reify ws-fmt/Format @@ -26,21 +45,6 @@ (js/Uint8Array. (.-buffer (packet/encode v)))))) -(def payload-formats - {:edn payload-fmt/edn - :raw payload-fmt/raw - :string payload-fmt/string - :transit payload-fmt/transit - :json payload-fmt/json}) - - -(defn- find-payload-format [fmt] - (info :find-payload-format :fmt fmt) - (cond - (satisfies? PayloadFormat fmt) fmt - (contains? payload-formats fmt) (get payload-formats fmt))) - - (defn- app-topic->broker-topic [{{root-topic :root-topic} :config} app-topic] (if root-topic (str root-topic "/" app-topic) @@ -225,22 +229,44 @@ [nil client])) +(defn- client-id + "Yields `client-id` if provided. Otherwise generated of `client-id-prefix` (default \"otarta\") and random characters. The generated id is compliant with [MQTT-3.1.3-5] (ie matches [0-9a-zA-Z]{1-23})." + [{:keys [client-id client-id-prefix]}] + (if (some? client-id) + client-id + (let [prefix (or client-id-prefix "otarta")] + (-> prefix + (str (random-uuid)) + (string/replace #"-" "") + (subs 0 23))))) + + (defn client - "Accepts the following parameters: - - broker-url (required) - url of the form ws(s)://(user:password@)host:12345/path(#some/root-topic). -The root-topic is prepended to all subscribes/publishes and ensures that the client only needs to care about topics that are relevant for the application, e.g. \"temperature/current\" (instead of \"staging/sensor0/temperature/current\"). You can provide a default-topic-root. + "Initialize a client for publish/subscribe. + + Arguments: + - broker-url - url of the form ws(s)://(user:password@)host:12345/path(#some/root-topic). + The root-topic is prepended to all subscribes/publishes and ensures that the client only needs to care about topics that are relevant for the application, e.g. \"temperature/current\" (instead of \"staging/sensor0/temperature/current\"). You can provide a default-root-topic. + + Accepts the following options: - default-root-topic - root-topic used when broker-url does not contain one. This e.g. allows the client-logic to subscribe to \"#\" knowing that it won't subscribe to the root of a broker. - keep-alive (default 60) - maximum seconds between pings. - - client-id (default \"otarta-\") - Client Identifier used to connect to broker. + - client-id (default \"\" (max. 23 characters as per MQTT-spec)) - Client Identifier used to connect to broker. This should be unique accross all connected clients. +WARNING: Connecting with a client-id that's already in use results in the existing client being disconnected. + - client-id-prefix (default \"otarta\") - convenient to see in the logs of your broker where the client originates from without running the risk of clashing with existing client-id's. " - [{:keys [broker-url default-root-topic] :as opts}] - {:pre [broker-url]} - (let [default-opts {:keep-alive 60 :client-id (str "otarta-" (random-uuid))} - config (-> broker-url - (parse-broker-url {:default-root-topic default-root-topic}) - (merge default-opts) - (merge (select-keys opts [:client-id :keep-alive])))] - {:config config :stream (atom nil) :pinger (atom nil) :packet-identifier (atom 0)})) + ([broker-url] (client broker-url {})) + ([broker-url {:keys [default-root-topic] :as opts}] + (let [default-opts {:keep-alive 60} + config (-> broker-url + (parse-broker-url {:default-root-topic default-root-topic}) + (assoc :client-id (client-id opts)) + (merge default-opts) + (merge (select-keys opts [:keep-alive])))] + {:config config + :stream (atom nil) + :pinger (atom nil) + :packet-identifier (atom 0)}))) (defn connect @@ -259,38 +285,16 @@ The root-topic is prepended to all subscribes/publishes and ensures that the cli (start-pinger))))) -(defn generate-payload-formatter [read-write format] - (if-let [payload-format (find-payload-format format)] - (let [rw (get {:read payload-fmt/read :write payload-fmt/write} read-write) - empty-fmt (reify PayloadFormat - (read [_ _] "") - (write [_ _] (js/Uint8Array.))) - formatter (fn [{e? :empty? :as to-send}] - (info :formatter) - (let [try-format #(try (rw payload-format %) - (catch js/Error _ - (error :format-error) - nil)) - update-fn (if e? (partial rw empty-fmt) try-format) - formatted-payload (-> to-send :payload update-fn)] - (if (nil? formatted-payload) - [:format-error nil] - [nil (assoc to-send :payload formatted-payload)])))] - [nil formatter]) - [:unkown-format nil])) - - -(defn- publish* [{stream :stream :as client} app-topic msg {:keys [format] :or {format :string}}] - (info :publish :client client :app-topic app-topic :msg msg :format format) + +(defn- publish* [{stream :stream :as client} app-topic payload {:keys [format retain?] :or {format :string retain? false}}] + (info :publish :client client :app-topic app-topic :payload payload :format format) (go (let [{sink :sink} @stream - empty-msg? (or (nil? msg) (= "" msg)) to-publish {:topic (app-topic->broker-topic client app-topic) - :payload msg - :empty? empty-msg?} - [fmt-err formatted] (err->> format - (generate-payload-formatter :write) - (#(apply % (list to-publish))))] + :retain? retain? + :payload payload + :empty? (empty-payload? payload)} + [fmt-err formatted] (fmt/write format to-publish)] (if fmt-err [fmt-err nil] (do (>! sink (packet/publish formatted)) @@ -303,30 +307,30 @@ The root-topic is prepended to all subscribes/publishes and ensures that the cli Currently `err` is always nil as no check is done whether the underlying connection is active, nor whether the broker received the message (ie qos 0)." - ([client topic msg] (publish client topic msg {})) - ([client topic msg opts] + ([client topic payload] (publish client topic payload {})) + ([client topic payload opts] ( client connect - (publish* topic msg opts)))) + (publish* topic payload opts)))) -(defn- subscription-chan [{stream :stream :as client} topic-filter payload-formatter] - (let [{source :source} @stream +(defn- subscription-chan [{stream :stream :as client} topic-filter msg-reader] + (let [{source :source} @stream pkts-for-topic-filter (packet-filter {[:remaining-bytes :topic] (partial topic-filter-matches-topic? topic-filter)}) pkt->msg (fn [{{:keys [retain? dup? qos]} :first-byte {topic :topic} :remaining-bytes {payload :payload} :extra}] - {:dup? dup? - :empty? (-> payload .-byteLength zero?) - :payload payload - :qos qos - :retained? retain? - :topic (broker-topic->app-topic client topic)}) + {:dup? dup? + :empty? (empty-payload? payload) + :payload payload + :qos qos + :retain? retain? + :topic (broker-topic->app-topic client topic)}) subscription-xf (comp pkts-for-topic-filter (map pkt->msg) - (map (comp second payload-formatter)) + (map (comp second msg-reader)) (remove nil?))] [nil (capture-all-packets source subscription-xf)])) @@ -338,7 +342,7 @@ The root-topic is prepended to all subscribes/publishes and ensures that the cli (let [{:keys [sink source]} @stream topic-filter (app-topic->broker-topic client app-topic-filter) [sub-err sub-ch] (err->> format - (generate-payload-formatter :read) + fmt/read (subscription-chan client topic-filter)) pktid (next-packet-identifier client) sub-pkt (packet/subscribe {:topic-filter topic-filter diff --git a/src/otarta/format.cljs b/src/otarta/format.cljs new file mode 100644 index 0000000000000000000000000000000000000000..35a784ff00a386d5586b690bf8926dff9bdb1f34 --- /dev/null +++ b/src/otarta/format.cljs @@ -0,0 +1,174 @@ +(ns otarta.format + (:refer-clojure :exclude [empty -write]) + (:require + [cljs.reader :as reader] + [cognitect.transit :as transit] + [goog.crypt :as crypt] + [otarta.util :refer-macros [err-> err->>]] + [huon.log :refer [debug info warn error]])) + + +(defprotocol PayloadFormat + "Implement read and write operation for reading and writing data to Uint8Array. + + When implementing these functions keep in mind that: + - the format is bypassed for messages containing {:empty? true} (ie no need to handle writing/reading \"\") + - an error should be thrown when reading/writings fails (or would write non-readable data). This signals to the caller that the formatting failed." + (-read [format arraybuffer]) + (-write [format value])) + + +(def empty + (reify PayloadFormat + (-read [_ _] "") + (-write [_ _] (js/Uint8Array.)))) + + +(def raw + (reify PayloadFormat + (-read [_ buff] buff) + (-write [_ v] v))) + + +(def string + (reify PayloadFormat + (-read [_ buff] + (info :read-string) + (crypt/utf8ByteArrayToString buff)) + (-write [_ v] + (info :write-string {:value v}) + (assert (string? v)) + (.from js/Uint8Array (crypt/stringToUtf8ByteArray v))))) + + +(def json + "Read and write data encoded in json" + (reify PayloadFormat + (-read [_ buff] + (info :read-json) + (let [s (-read string buff)] + (info :read-json {:string s}) + (->> s js/JSON.parse js->clj))) + (-write [_ v] + (info :write-json {:value v}) + (->> v clj->js js/JSON.stringify (-write string))))) + + +(def edn + "Read and write data encoded in edn. +`write` is strict in that it checks whether what it will write is actually readable. +This makes writing records impossible." + (reify PayloadFormat + (-read [_ buff] + (info :read-edn) + (let [s (-read string buff)] + (debug :read-edn {:string s}) + (reader/read-string s))) + (-write [_ v] + (info :write-edn {:value v}) + (let [to-write (prn-str v) + readable? (partial reader/read-string)] + (readable? to-write) + (-write string to-write))))) + + +(def transit + "Read and write data encoded in transit+json." + (reify PayloadFormat + (-read [_ buff] + (info :read-transit) + (->> buff + (-read string) + (transit/read (transit/reader :json)))) + (-write [_ v] + (info :write-transit) + (->> v + (transit/write (transit/writer :json)) + (-write string))))) + + +(def payload-formats + {:edn edn + :empty empty + :raw raw + :string string + :transit transit + :json json}) + + +(defn find-payload-format [fmt] + (info :find-payload-format :fmt fmt) + (cond + (satisfies? PayloadFormat fmt) fmt + (contains? payload-formats fmt) (get payload-formats fmt))) + + +(defn msg-formatter [rw format] + (if-let [payload-format (find-payload-format format)] + (let [formatter (fn [{e? :empty? :as msg}] + (info :formatter) + (let [try-format #(try (rw payload-format %) + (catch js/Error _ + (error :format-error) + nil)) + format-fn (if e? (partial rw empty) try-format) + formatted-payload (-> msg :payload format-fn)] + (if (nil? formatted-payload) + [:format-error nil] + [nil (assoc msg :payload formatted-payload)])))] + [nil formatter]) + [:unkown-format nil])) + + +(defn write + "Applies `format` to :payload of `msg` or yields a writer when no `msg` given. + When `msg` has [:empty? true], the empty-format is used instead of `format`. + + `format` can be one of `payload-formats`, or a reify of PayloadFormat. + + Yields [err msg-with-formatted-payload] + Possible err's: + - :unknown-format + - :format-error + + Examples: + (write :json {:payload \"a\") + ;; => [nil {:payload #object[Uint8Array 34,97,34]}] + + (write :json {:empty? true :payload \"a\"} + ;; => [nil {:payload #object[Uint8Array ]}] +" + ([format] + (err->> format + (msg-formatter -write))) + ([format msg] + (err-> format + (write) + (apply (list msg))))) + + +(defn read + "Applies `format` to :payload of `msg` or yields a reader when no `msg` given. + When `msg` contains [:empty? true], the empty-format is used instead of `format`. + + `format` can be one of payload-formats, or a reify of PayloadFormat. + + Yields [err msg-with-formatted-payload] + Possible err's: + - :unknown-format + - :format-error + + Examples: + (read :json {:payload #js [34,97,34]) + ;; => [nil {:payload \"a\"}] + + (read :json {:empty? true :payload #js [34,97,34]) + ;; => [nil {:empty? true :payload \"\"}] +" + ([format] + (err->> format + (msg-formatter -read))) + ([format msg] + (err-> format + read + (apply (list msg))))) diff --git a/src/otarta/main.cljs b/src/otarta/main.cljs index fc91e585e018b1e605b4e3f1eaef03f5ae306b7f..14e9273a2947e4baf74195190a9bfa28800e034e 100644 --- a/src/otarta/main.cljs +++ b/src/otarta/main.cljs @@ -17,7 +17,7 @@ (defn handle-sub [broker-url topic-filter] (info :handle-sub :broker-url broker-url :topic-filter topic-filter) (go - (reset! client (mqtt/client {:broker-url broker-url})) + (reset! client (mqtt/client broker-url)) (let [[err {sub-ch :ch}] (> s js/JSON.parse js->clj))) - (write [_ v] - (info :write-json {:value v}) - (->> v clj->js js/JSON.stringify (write string))))) - - -(def edn - "Read and write data encoded in edn. -`write` is strict in that it checks whether what it will write is actually readable. -This makes writing records impossible." - (reify PayloadFormat - (read [_ buff] - (info :read-edn) - (let [s (read string buff)] - (debug :read-edn {:string s}) - (reader/read-string s))) - (write [_ v] - (info :write-edn {:value v}) - (let [to-write (prn-str v) - readable? (partial reader/read-string)] - (readable? to-write) - (write string to-write))))) - - -(def transit - "Read and write data encoded in transit+json." - (reify PayloadFormat - (read [_ buff] - (info :read-transit) - (->> buff - (read string) - (transit/read (transit/reader :json)))) - (write [_ v] - (info :write-transit) - (->> v - (transit/write (transit/writer :json)) - (write string))))) diff --git a/test/otarta/core_test.cljs b/test/otarta/core_test.cljs index 7c93014a611483da480b9de7f484557aec822905..daf443f572faf00c526094f0ac1105c160d7e71f 100644 --- a/test/otarta/core_test.cljs +++ b/test/otarta/core_test.cljs @@ -7,7 +7,7 @@ [goog.crypt :as crypt] [huon.log :as log :refer [debug info warn error]] [otarta.core :as sut] - [otarta.payload-format :as payload-fmt :refer [PayloadFormat]] + [otarta.format :as fmt :refer [PayloadFormat]] [otarta.packet :as pkt] [otarta.util :refer-macros [err-> err->>]] [otarta.test-helpers :as helpers :refer [test-async sub?]])) @@ -44,8 +44,23 @@ (deftest client-test - (testing "raises when no broker-url provided" - (is (thrown-with-msg? js/Error #"Assert failed: broker-url" (sut/client {}))))) + (testing "config" + (testing "client-id" + (testing "when none provided has default prefix, correct chars and length [MQTT-3.1.3-5]" + (is (re-find #"otarta[a-zA-Z0-9]{17}$" + (-> "ws://localhost" (sut/client) :config :client-id)))) + (testing "when prefix provided has correct chars and length" + (is (re-find #"origin[a-zA-Z0-9]{17}$" + (-> "ws://localhost" + (sut/client {:client-id-prefix "origin"}) + :config + :client-id)))) + (testing "when client-id provided it's used as-is" + (is (= "custom-client" + (-> "ws://localhost" + (sut/client {:client-id "custom-client"}) + :config + :client-id))))))) (deftest topic-filter-matches-topic?-test @@ -116,7 +131,7 @@ :topic topic :payload (str->uint8array msg)}))) subscribe! #(-> %3 - (err->> (sut/generate-payload-formatter :read) + (err->> (fmt/read) (sut/subscription-chan %1 %2)) second) messages-received (fn [ch] @@ -235,40 +250,3 @@ (test-async (go (is (= [true false] (->> sub messages-received uint8array "anything")}))) - (is (.equals goog.object (js/Uint8Array.) - (:payload (second (wfut {:empty? true - :payload nil}))))))) - - - (testing "yields :error when formatter fails" - (let [[_ read-json] (sut/generate-payload-formatter :read :json) - [_ write-edn] (sut/generate-payload-formatter :write :edn)] - (is (sub? [:format-error] - (read-json {:payload (str->uint8array "all but json")}))) - (is (sub? [:format-error] - (write-edn {:payload #"no edn"})))))) diff --git a/test/otarta/format_test.cljs b/test/otarta/format_test.cljs new file mode 100644 index 0000000000000000000000000000000000000000..bde071d9303ba5edb52d708904810e6af4bed491 --- /dev/null +++ b/test/otarta/format_test.cljs @@ -0,0 +1,187 @@ +(ns otarta.format-test + (:require + [cljs.test :refer [deftest is testing are]] + [goog.crypt :as crypt] + [goog.object] + [huon.log :as log :refer [debug info warn error]] + [otarta.format :as sut :refer [PayloadFormat]] + [otarta.test-helpers :refer [sub?]])) + +(comment + ;; handy to create assertions: + (println (crypt/stringToUtf8ByteArray "{\"a\":1}")) + ) + +;; formats +;; +(defn- write-and-read [fmt] + #(->> % (sut/-write fmt) (sut/-read fmt))) + + +(deftest string-test + (testing "handling strings" + (let [fut (write-and-read sut/string)] + (are [value] (= value (fut value)) + "MQTT" + "👽" + "some long string"))) + + (testing "handling non-strings" + (let [fut (partial sut/-write sut/string)] + (is (thrown? js/Error (fut 1))) + (is (thrown? js/Error (fut [])))))) + + +(deftest ^{:focus true} json-test + (testing "reading" + (are [buff expected] (= expected (sut/-read sut/json buff)) + #js [123 34 97 34 58 49 125] + {"a" 1} + + #js [123 34 117 110 100 101 114 115 99 111 114 101 100 95 107 101 121 34 58 49 125] + {"underscored_key" 1})) + + (testing "reading non-json throws error" + (are [s] (thrown? js/Error (sut/-read sut/json (sut/-write sut/string s))) + ";; comment" + "hello")) + + (testing "writing" + (are [expected value] (.equals goog.object + (js/Uint8Array. expected) + (sut/-write sut/json value)) + ;; stringified and keywordize gets lost in translation + #js [123 34 97 34 58 49 125] + {"a" 1} + #js [123 34 97 34 58 49 125] + {:a 1} + + #js [123 34 117 110 100 101 114 115 99 111 114 101 100 95 107 101 121 34 58 49 125] + {"underscored_key" 1}))) + + +(deftest edn-test + (testing "success" + (let [fut (write-and-read sut/edn)] + (are [value] (= value (fut value)) + {:a 1} + {"a" 1} + {"underscored_key" {:a {:b 2}}} + {:a {:b {:c ["👽" '(1 2 3)]}}}))) + + (testing "writing non-edn" + (defrecord Foo [a]) + (are [s] (thrown? js/Error (sut/-write sut/edn s)) + #"regex" + (fn []) + (->Foo 1))) + + (testing "reading non-edn" + (are [s] (thrown? js/Error (sut/-read sut/edn (sut/-write sut/string s))) + "#\"regex\"" + "/nonsense/"))) + + +(deftest transit-test + (testing "success" + (let [fut (write-and-read sut/transit)] + (are [value] (= value (fut value)) + {:a 1} + {"a" 1} + {"underscored_key" {:a {:b 2}}} + {:a {:b {:c ["👽" '(1 2 3)]}}}))) + + (testing "writing non-transit" + (defrecord Bar [a]) + (are [s] (thrown? js/Error (sut/-write sut/transit s)) + #"regex" + (fn []) + (->Bar 1))) + + (testing "reading non-transit" + (are [s] (thrown? js/Error (sut/-read sut/transit (sut/-write sut/string s))) + "#\"regex\"" + "/nonsense/"))) + +;; read message +(deftest read-test + (testing "yields error for unknown format" + (is (sub? [:unkown-format] + (sut/read :foo)))) + + (testing "yields no error for known formats" + (is (sub? [nil] + (sut/read :json))) + (is (sub? [nil] + (sut/read :transit)))) + + (testing "custom format" + (let [my-fmt (reify PayloadFormat + (-read [_ _] "READ") + (-write [_ _] "WRITTEN"))] + (testing "is an acceptable format" + (is (some? (-> my-fmt sut/read second)))) + + (testing "is applied to message's payload" + (is (sub? [nil {:payload "READ"}] + (-> my-fmt (sut/read {:payload #js []}))))) + + (testing "is bypassed when messsage is empty" + (is (sub? [nil {:payload ""}] + (-> my-fmt (sut/read {:empty? true :payload #js []}))))))) + + (testing "yields :format-error for messages with unreadable payloads" + (let [msg-with-payload (fn [s] {:payload (crypt/stringToUtf8ByteArray s)})] + (are [fmt pl error?] (= error? + (-> fmt + (sut/read (msg-with-payload pl)) + first + (= :format-error))) + :json "no json!" true + :json "{\"a\":1}" false + :edn "{\"a\":1}" false + :edn "#\"no edn!\"" true)))) + +(deftest write-test + (testing "yields error for unknown format" + (is (sub? [:unkown-format] + (sut/write :foo)))) + + (testing "yields no error for known formats" + (is (sub? [nil] + (sut/write :json))) + (is (sub? [nil] + (sut/write :transit)))) + + (testing "custom format" + (let [my-fmt (reify PayloadFormat + (-read [_ _] "READ") + (-write [_ _] "WRITTEN"))] + (testing "is an acceptable format" + (is (some? (-> my-fmt sut/write second)))) + + (testing "is applied to message's payload" + (is (sub? [nil {:payload "WRITTEN"}] + (-> my-fmt (sut/write {:payload "anything"}))))) + + (testing "is bypassed when messsage is empty" + (is (.equals goog.object + (js/Uint8Array.) + (-> my-fmt + (sut/write {:empty? true :payload "anything"}) + second + :payload)))))) + + (testing "yields :format-error for messages with unwriteable payloads" + (let [msg-with-payload (fn [s] {:payload s}) + some-record (defrecord Baz [a])] + (are [fmt pl error?] (= error? + (-> fmt + (sut/write (msg-with-payload pl)) + first + (= :format-error))) + :string 1 true + :string "some string" false + :edn #"no edn!" true + :edn (->Baz 1) true + :edn "real edn" false)))) diff --git a/test/otarta/payload_format_test.cljs b/test/otarta/payload_format_test.cljs deleted file mode 100644 index ff687d687a81d289d3866bdacd77c1b14bcb034b..0000000000000000000000000000000000000000 --- a/test/otarta/payload_format_test.cljs +++ /dev/null @@ -1,101 +0,0 @@ -(ns otarta.payload-format-test - (:require - [cljs.test :refer [deftest is testing are]] - [goog.crypt :as crypt] - [goog.object] - [huon.log :as log :refer [debug info warn error]] - [otarta.payload-format :as sut])) - -(comment - ;; handy to create assertions: - (println (crypt/stringToUtf8ByteArray "{\"a\":1}")) - ) - - -(defn- write-and-read [fmt] - #(->> % (sut/write fmt) (sut/read fmt))) - - -(deftest string-test - (testing "handling strings" - (let [fut (write-and-read sut/string)] - (are [value] (= value (fut value)) - "MQTT" - "👽" - "some long string"))) - - (testing "handling non-strings" - (let [fut (partial sut/write sut/string)] - (is (thrown? js/Error (fut 1))) - (is (thrown? js/Error (fut [])))))) - - -(deftest json-test - (testing "reading" - (are [buff expected] (= expected (sut/read sut/json buff)) - #js [123 34 97 34 58 49 125] - {"a" 1} - - #js [123 34 117 110 100 101 114 115 99 111 114 101 100 95 107 101 121 34 58 49 125] - {"underscored_key" 1})) - - (testing "reading non-json throws error" - (are [s] (thrown? js/Error (sut/read sut/json (sut/write sut/string s))) - ";; comment" - "hello")) - - (testing "writing" - (are [expected value] (.equals goog.object - expected (sut/write sut/json value)) - ;; stringified and keywordize gets lost in translation - #js [123 34 97 34 58 49 125] - {"a" 1} - #js [123 34 97 34 58 49 125] - {:a 1} - - #js [123 34 117 110 100 101 114 115 99 111 114 101 100 95 107 101 121 34 58 49 125] - {"underscored_key" 1}))) - - -(deftest edn-test - (testing "success" - (let [fut (write-and-read sut/edn)] - (are [value] (= value (fut value)) - {:a 1} - {"a" 1} - {"underscored_key" {:a {:b 2}}} - {:a {:b {:c ["👽" '(1 2 3)]}}}))) - - (testing "writing non-edn" - (defrecord Foo [a]) - (are [s] (thrown? js/Error (sut/write sut/edn s)) - #"regex" - (fn []) - (->Foo 1))) - - (testing "reading non-edn" - (are [s] (thrown? js/Error (sut/read sut/edn (sut/write sut/string s))) - "#\"regex\"" - "/nonsense/"))) - - -(deftest transit-test - (testing "success" - (let [fut (write-and-read sut/transit)] - (are [value] (= value (fut value)) - {:a 1} - {"a" 1} - {"underscored_key" {:a {:b 2}}} - {:a {:b {:c ["👽" '(1 2 3)]}}}))) - - (testing "writing non-transit" - (defrecord Bar [a]) - (are [s] (thrown? js/Error (sut/write sut/transit s)) - #"regex" - (fn []) - (->Bar 1))) - - (testing "reading non-transit" - (are [s] (thrown? js/Error (sut/read sut/transit (sut/write sut/string s))) - "#\"regex\"" - "/nonsense/")))