diff --git a/README.md b/README.md index 1f3dea6..eb604e4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ re-graph is a graphql client for Clojure and ClojureScript with bindings for [re-frame](https://github.com/Day8/re-frame) applications. +## Upgrade notice + +:fire: Version `0.2.0` was recently released with breaking API changes. Please read the [Upgrade](UPGRADE.md) guide for more information. + ## Notes This library behaves like the popular [Apollo client](https://github.com/apollographql/subscriptions-transport-ws) @@ -69,26 +73,26 @@ Call the `init` function to bootstrap it and then use `subscribe`, `unsubscribe` ;; initialise re-graph, possibly including configuration options (see below) (re-graph/init {}) -(defn on-thing [{:keys [data errors] :as payload}] +(defn on-thing [{:keys [data errors] :as response}] ;; do things with data )) ;; start a subscription, with responses sent to the callback-fn provided -(re-graph/subscribe :my-subscription-id ;; this id should uniquely identify this subscription - "{ things { id } }" ;; your graphql query - {:some "variable"} ;; arguments map - on-thing) ;; callback-fn when messages are recieved +(re-graph/subscribe {:id :my-subscription-id ;; this id should uniquely identify this subscription + :query "{ things { id } }" ;; your graphql query + :variables {:some "variable"} ;; arguments map + :callback on-thing}) ;; callback-fn when messages are recieved ;; stop the subscription -(re-graph/unsubscribe :my-subscription-id) +(re-graph/unsubscribe {:id :my-subscription-id}) ;; perform a query, with the response sent to the callback event provided -(re-graph/query "{ things { id } }" ;; your graphql query - {:some "variable"} ;; arguments map - on-thing) ;; callback event when response is recieved +(re-graph/query {:query "{ things { id } }" ;; your graphql query + :variables {:some "variable"} ;; arguments map + :callback on-thing}) ;; callback event when response is recieved ;; shut re-graph down when finished -(re-graph/destroy) +(re-graph/destroy {}) ``` ### re-frame users @@ -103,29 +107,31 @@ Dispatch the `init` event to bootstrap it and then use the `:subscribe`, `:unsub (re-frame/reg-event-db ::on-thing - (fn [db [_ {:keys [data errors] :as payload}]] - ;; do things with data e.g. write it into the re-frame database - )) + [re-frame/unwrap] + (fn [db {:keys [response]}] + (let [{:keys [data errors]} response] + ;; do things with data e.g. write it into the re-frame database + ))) ;; start a subscription, with responses sent to the callback event provided (re-frame/dispatch [::re-graph/subscribe - :my-subscription-id ;; this id should uniquely identify this subscription - "{ things { id } }" ;; your graphql query - {:some "variable"} ;; arguments map - [::on-thing]]) ;; callback event when messages are recieved + {:id :my-subscription-id ;; this id should uniquely identify this subscription + :query "{ things { id } }" ;; your graphql query + :variables {:some "variable"} ;; arguments map + :callback [::on-thing]}]) ;; callback event when messages are recieved ;; stop the subscription -(re-frame/dispatch [::re-graph/unsubscribe :my-subscription-id]) +(re-frame/dispatch [::re-graph/unsubscribe {:id :my-subscription-id}]) ;; perform a query, with the response sent to the callback event provided (re-frame/dispatch [::re-graph/query - :my-query-id ;; unique id for this query - "{ things { id } }" ;; your graphql query - {:some "variable"} ;; arguments map - [::on-thing]]) ;; callback event when response is recieved + {:id :my-query-id ;; unique id for this query + :query "{ things { id } }" ;; your graphql query + :variables {:some "variable"} ;; arguments map + :callback [::on-thing]}]) ;; callback event when response is recieved ;; shut re-graph down when finished -(re-frame/dispatch [::re-graph/destroy]) +(re-frame/dispatch [::re-graph/destroy {}]) ``` ### Options @@ -135,23 +141,23 @@ Options can be passed to the init event, with the following possibilities: ```clojure (re-frame/dispatch [::re-graph/init - {:ws {:url "wss://foo.io/graphql-ws" ;; override the websocket url (defaults to /graphql-ws, nil to disable) - :sub-protocol "graphql-ws" ;; override the websocket sub-protocol (defaults to "graphql-ws") - :reconnect-timeout 5000 ;; attempt reconnect n milliseconds after disconnect (defaults to 5000, nil to disable) - :resume-subscriptions? true ;; start existing subscriptions again when websocket is reconnected after a disconnect (defaults to true) - :connection-init-payload {} ;; the payload to send in the connection_init message, sent when a websocket connection is made (defaults to {}) - :impl {} ;; implementation-specific options (see hato for options, defaults to {}, may be a literal or a function that returns the options) - :supported-operations #{:subscribe ;; declare the operations supported via websocket, defaults to all three - :query ;; if queries/mutations must be done via http set this to #{:subscribe} only - :mutate} - } - - :http {:url "http://bar.io/graphql" ;; override the http url (defaults to /graphql) - :impl {} ;; implementation-specific options (see clj-http or hato for options, defaults to {}, may be a literal or a function that returns the options) - :supported-operations #{:query ;; declare the operations supported via http, defaults to :query and :mutate + {:ws {:url "wss://foo.io/graphql-ws" ;; override the websocket url (defaults to /graphql-ws, nil to disable) + :sub-protocol "graphql-ws" ;; override the websocket sub-protocol (defaults to "graphql-ws") + :reconnect-timeout 5000 ;; attempt reconnect n milliseconds after disconnect (defaults to 5000, nil to disable) + :resume-subscriptions? true ;; start existing subscriptions again when websocket is reconnected after a disconnect (defaults to true) + :connection-init-payload {} ;; the payload to send in the connection_init message, sent when a websocket connection is made (defaults to {}) + :impl {} ;; implementation-specific options (see hato for options, defaults to {}, may be a literal or a function that returns the options) + :supported-operations #{:subscribe ;; declare the operations supported via websocket, defaults to all three + :query ;; if queries/mutations must be done via http set this to #{:subscribe} only :mutate} - } - }]) + } + + :http {:url "http://bar.io/graphql" ;; override the http url (defaults to /graphql) + :impl {} ;; implementation-specific options (see clj-http or hato for options, defaults to {}, may be a literal or a function that returns the options) + :supported-operations #{:query ;; declare the operations supported via http, defaults to :query and :mutate + :mutate} + } + }]) ``` Either `:ws` or `:http` can be set to nil to disable the WebSocket or HTTP protocols. @@ -165,34 +171,38 @@ All function/event signatures now take an optional instance-name as the first ar (require '[re-graph.core :as re-graph]) ;; initialise re-graph for service A -(re-graph/init :service-a {:ws-url "wss://a.com/graphql-ws}) +(re-graph/init {:instance-id :service-a + :ws {:url "wss://a.com/graphql-ws}}) ;; initialise re-graph for service B -(re-graph/init :service-b {:ws-url "wss://b.net/api/graphql-ws}) +(re-graph/init {:instance-id :service-b + :ws {:url "wss://b.com/api/graphql-ws}}) (defn on-a-thing [{:keys [data errors] :as payload}] ;; do things with data from service A )) ;; subscribe to service A, events will be sent to the on-a-thing callback -(re-graph/subscribe :service-a ;; the instance-name you want to talk to - :my-subscription-id ;; this id should uniquely identify this subscription for this service - "{ things { a } }" - on-a-thing) +(re-graph/subscribe {:instance-id :service-a ;; the instance-name you want to talk to + :id :my-subscription-id ;; this id should uniquely identify this subscription for this service + :query "{ things { a } }" + :callback on-a-thing}) (defn on-b-thing [{:keys [data errors] :as payload}] ;; do things with data from service B )) ;; subscribe to service B, events will be sent to the on-b-thing callback -(re-graph/subscribe :service-b ;; the instance-name you want to talk to - :my-subscription-id - "{ things { b } }" - on-b-thing) +(re-graph/subscribe {:instance-id :service-b ;; the instance-name you want to talk to + :id :my-subscription-id + :query "{ things { a } }" + :callback on-b-thing}) ;; stop the subscriptions -(re-graph/unsubscribe :service-a :my-subscription-id) -(re-graph/unsubscribe :service-b :my-subscription-id) +(re-graph/unsubscribe {:instance-id :service-a + :id :my-subscription-id}) +(re-graph/unsubscribe {:instance-id :service-b + :id :my-subscription-id}) ``` ## Authentication @@ -241,7 +251,7 @@ When using re-graph within a browser, site cookies are shared between HTTP and W When using re-graph with Clojure, however, some configuration is necessary to ensure that the same cookie store is used for both HTTP and WebSocket connections. -Before initializing re-graph, create a common HTTP client. +Before initialising re-graph, create a common HTTP client. ``` (ns user @@ -254,7 +264,7 @@ Before initializing re-graph, create a common HTTP client. See the [hato documentation](https://github.com/gnarroway/hato) for all the supported configuration options. -When initializing re-graph, configure both the HTTP and WebSocket connections with this client: +When initialising re-graph, configure both the HTTP and WebSocket connections with this client: ``` (re-graph/init {:http {:impl {:http-client http-client}} diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..aa28cb0 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,187 @@ +# Upgrading from 0.1.x to 0.2.0 + +- [Rationale](#rationale) +- [Spec and Instrumentation](#spec-and-instrumentation) +- [API changes](#api-changes) +- [Legacy API](#legacy-api) + +## Rationale + +The signature of re-graph events had become overloaded with two features: + - Multi instances, where an `instance-name` could be optionally supplied as the first argument to address a particular instance of re-graph + - Query deduplication and cancellation, where a `query-id` could be optionally supplied as the first argument for queries and mutations + +In the case where you were supplying both `instance-name` and `query-id` then these would be given as the first two arguments respectively. + +This made destructuring re-graph events difficult and error prone, resulting in hard-to-read error messages when something went wrong. + +At the same time, [re-frame](https://github.com/day8/re-frame/issues/644) was discussing the same issue on a more generic level, +namely that positional significance of arguments becomes problematic with large numbers of arguments. To quote their rationale: + +> For software systems, the arrow of time and the arrow of entropy are aligned. Changes inevitably happen. +> +> When we use vectors in both these situations we are being "artificially placeful". The elements of the vector have names, but we are pretending they have indexes. That little white lie has some benefits (is terse and minimal) but it also makes the use of these structures a bit fragile WRT to change. Names (truth!) are more flexible and robust than indexes (a white lie). +> +> The benefit of using names accumulate on two time scales: +> +> - within a single execution of the app (as data moves around on the inside of your app) +> - across the maintenance cycle of the app's codebase (as your app's code changes in response to user feedback) + +re-frame has added an `unwrap` interceptor to make working with a single map argument easier, and re-graph now follows +this convention with consistently-named keys to remove ambiguity. + +## Spec and Instrumentation + +The re-graph API is now described by [re-graph.spec](https://github.com/oliyh/re-graph/blob/re-frame-maps/src/re_graph/spec.cljc). +All functions have specs and the implementation re-frame events also have an assertion on the event payload. + +This means you can validate your calls to re-graph by turning on instrumentation (for the Clojure/script API) and/or assertions (for re-frame users): + +```clj +;; instrumentation +(require '[clojure.spec.test.alpha :as stest]) +(stest/instrument) + +;; assertions +(require '[clojure.spec.alpha :as s]) +(s/check-asserts true) +``` + +## API changes + +All re-frame events (and all vanilla functions) in re-graph's `core` api now accept a single map argument. + +Queries, mutations and subscriptions are closely aligned, accepting `:query`, `:variables`, `:callback` and `:id`. + +Note that it is expected re-frame callbacks also follow the same convention of a single map argument, with `:response` containing the response from the server. +Vanilla Clojure/script API callback functions remain unchanged. + +Examples below show mostly the re-frame events but the Clojure/script API functions take the exact same map arguments. + +### Examples + +#### re-frame callbacks + +A re-frame callback that was originally defined and invoked as follows: +```clj +(rf/reg-event-db + ::my-callback + (fn [db [_ response]] + (assoc db :response response))) + +(rf/dispatch [::re-graph/query "{ some { thing } }" {:some "variable"} [::my-callback]]) +``` +Should now expect the response to be under the `:response` key in a single map argument (destructured here using `unwrap`): +```clj +(rf/reg-event-db + ::my-callback + [rf/unwrap] + (fn [db {:keys [response]}] + (assoc db :response response))) + +(rf/dispatch [::re-graph/query {:query "{ some { thing } }" + :variables {:some "variable"} + :callback [::my-callback]}]) +``` + +Any partial params supplied like `my-opts` shown here: +```clj +(rf/reg-event-db + ::my-callback + (fn [db [_ my-opts response]] + (assoc db :response response))) + +(rf/dispatch [::re-graph/query "{ some { thing } }" {:some "variable"} [::my-callback {:my-opts true}]]) +``` +Should now be used like this: +```clj +(rf/reg-event-db + ::my-callback + [rf/unwrap] + (fn [db {:keys [my-opts response]}] + (assoc db :response response))) + +(rf/dispatch [::re-graph/query {:query "{ some { thing } }" + :variables {:some "variable"} + :callback [::my-callback {:my-opts true}]}]) +``` + +#### Simple query + +A query with some variables and a callback: +```clj +(rf/dispatch [::re-graph/query "{ some { thing } }" {:some "variable"} [::my-callback]]) +``` +becomes +```clj +(rf/dispatch [::re-graph/query {:query "{ some { thing } }" + :variables {:some "variable"} + :callback [::my-callback]}]) +``` + +Or, if you were using the vanilla Clojure/script API: +```clj +(re-graph/query "{ some { thing } }" {:some "variable"} (fn [response] ...)) +``` +becomes +```clj +(re-graph/query {:query "{ some { thing } }" + :variables {:some "variable"} + :callback (fn [response] ...)}) +``` + + +#### Subscriptions + +Subscriptions have always required an id, which could optionally be used for queries and mutations as well. These are now named `:id`: +```clj +(rf/dispatch [::re-graph/subscribe :my-subscription-id "{ some { thing } }" {:some "variable"} [::my-callback]]) +``` +becomes +```clj +(rf/dispatch [::re-graph/subscribe {:id :my-subscription-id + :query "{ some { thing } }" + :variables {:some "variable"} + :callback [::my-callback]}]) +``` + +And to unsubscribe: +```clj +(rf/dispatch [::re-graph/unsubscribe :my-subscription-id]) +``` +becomes +```clj +(rf/dispatch [::re-graph/unsubscribe {:id :my-subscription-id}]) +``` + +#### Multiple instances + +You can supply `:instance-id` to the `init` (and `re-init`) events and to any subsequent queries: +```clj +(rf/dispatch [::re-graph/init :my-service {:ws {:url "https://my.service"}}]) + +(rf/dispatch [::re-graph/query :my-service "{ some { thing } }" {:some "variable"} [::my-callback]]) +``` +becomes +```clj +(rf/dispatch [::re-graph/init {:instance-id :my-service + :ws {:url "https://my.service"}}]) + +(rf/dispatch [::re-graph/query {:instance-id :my-service + :query "{ some { thing } }" + :variables {:some "variable"} + :callback [::my-callback]}]) +``` + +And to destroy: +```clj +(rf/dispatch [::re-graph/destroy :my-service]) +``` +becomes +```clj +(rf/dispatch [::re-graph/destroy {:instance-id :my-service}]) +``` + +## Legacy API + +The original API is available at `re-graph.core-deprecated`. This will be removed in a future release. diff --git a/clj-http-gniazdo/project.clj b/clj-http-gniazdo/project.clj index 3b12164..44da33e 100644 --- a/clj-http-gniazdo/project.clj +++ b/clj-http-gniazdo/project.clj @@ -1,3 +1,3 @@ -(defproject re-graph.clj-http-gniazdo "0.1.18-SNAPSHOT" +(defproject re-graph.clj-http-gniazdo "0.2.0-SNAPSHOT" :dependencies [[clj-http "3.12.3"] [stylefruits/gniazdo "1.2.0"]]) diff --git a/deps.edn b/deps.edn index ae10ec3..e06a368 100644 --- a/deps.edn +++ b/deps.edn @@ -1,6 +1,7 @@ {:deps {re-frame {:mvn/version "1.2.0"} cljs-http {:mvn/version "0.1.46"} cheshire {:mvn/version "5.10.2"} - re-graph.hato {:mvn/version "0.1.17-SNAPSHOT"} + org.clojure/spec.alpha {:mvn/version "0.3.218"} + re-graph.hato {:mvn/version "0.2.0"} org.clojure/tools.logging {:mvn/version "1.2.4"}} :paths ["src"]} diff --git a/hato/project.clj b/hato/project.clj index 11fe1d1..a639069 100644 --- a/hato/project.clj +++ b/hato/project.clj @@ -1,2 +1,2 @@ -(defproject re-graph.hato "0.1.18-SNAPSHOT" +(defproject re-graph.hato "0.2.0-SNAPSHOT" :dependencies [[hato "0.8.2"]]) diff --git a/project.clj b/project.clj index fd7e44f..9004f9b 100644 --- a/project.clj +++ b/project.clj @@ -1,4 +1,4 @@ -(defproject re-graph "0.1.18-SNAPSHOT" +(defproject re-graph "0.2.0-SNAPSHOT" :description "GraphQL client for re-frame applications" :url "https://github.com/oliyh/re-graph" :license {:name "Eclipse Public License" @@ -19,6 +19,7 @@ [cljs-http "0.1.46"] [org.clojure/tools.logging "1.2.4"] [cheshire "5.10.2"] + [org.clojure/spec.alpha "0.3.218"] [re-graph.hato :version]] :profiles {:provided {:dependencies [[org.clojure/clojure "1.10.3"] [org.clojure/clojurescript "1.11.4"]]} diff --git a/src/re_graph/core.cljc b/src/re_graph/core.cljc index 74f4157..231a023 100644 --- a/src/re_graph/core.cljc +++ b/src/re_graph/core.cljc @@ -1,242 +1,242 @@ (ns re-graph.core (:require [re-frame.core :as re-frame] [re-graph.internals :as internals - :refer [interceptors default-instance-name]] + :refer [interceptors]] [re-graph.logging :as log] - [clojure.string :as string])) + [clojure.string :as string] + [clojure.spec.alpha :as s] + [re-graph.spec :as spec])) + +;; queries and mutations (re-frame/reg-event-fx ::mutate - interceptors - (fn [{:keys [db dispatchable-event instance-name]} [query-id query variables callback-event]] + (interceptors ::spec/mutate) + (fn [{:keys [db]} {:keys [id query variables callback] + :or {id (internals/generate-id)} + :as event-payload}] + (let [query (str "mutation " (string/replace query #"^mutation\s?" "")) websocket-supported? (contains? (get-in db [:ws :supported-operations]) :mutate)] (cond - (or (get-in db [:http :requests query-id]) - (get-in db [:subscriptions query-id])) + (or (get-in db [:http :requests id]) + (get-in db [:subscriptions id])) {} ;; duplicate in-flight mutation (and websocket-supported? (get-in db [:ws :ready?])) - {:db (assoc-in db [:subscriptions query-id] {:callback callback-event}) + {:db (assoc-in db [:subscriptions id] {:callback callback}) ::internals/send-ws [(get-in db [:ws :connection]) - {:id query-id + {:id id :type "start" :payload {:query query :variables variables}}]} (and websocket-supported? (:ws db)) - {:db (update-in db [:ws :queue] conj dispatchable-event)} + {:db (update-in db [:ws :queue] conj [::mutate event-payload])} :else - {:db (assoc-in db [:http :requests query-id] {:callback callback-event}) - ::internals/send-http [instance-name - query-id - (get-in db [:http :url]) - {:request (get-in db [:http :impl]) - :payload {:query query - :variables variables}}]})))) + {:db (assoc-in db [:http :requests id] {:callback callback}) + ::internals/send-http {:url (get-in db [:http :url]) + :request (get-in db [:http :impl]) + :payload {:query query + :variables variables} + :event (assoc event-payload :id id)}})))) (defn mutate - "Execute a GraphQL mutation. The arguments are: + "Execute a GraphQL mutation. See ::spec/mutate for the arguments" + [opts] + (re-frame/dispatch [::mutate (update opts :callback (fn [f] [::internals/callback {:callback-fn f}]))])) - [instance-name query-string variables callback] - - If the optional `instance-name` is not provided, the default instance is - used. The callback function will receive the result of the mutation as its - sole argument." - [& args] - (let [callback-fn (last args)] - (re-frame/dispatch (into [::mutate] (conj (vec (butlast args)) [::internals/callback callback-fn]))))) +(s/fdef mutate :args (s/cat :opts ::spec/mutate)) #?(:clj (def - ^{:doc "Executes a mutation synchronously. The arguments are: - - [instance-name query-string variables timeout] - - The `instance-name` and `timeout` are optional. The `timeout` is - specified in milliseconds."} + ^{:doc "Executes a mutation synchronously. + Options are per `mutate` with an additional optional `:timeout` specified in milliseconds."} mutate-sync (partial internals/sync-wrapper mutate))) (re-frame/reg-event-fx ::query - interceptors - (fn [{:keys [db dispatchable-event instance-name]} [query-id query variables callback-event]] + (interceptors ::spec/query) + (fn [{:keys [db]} {:keys [id query variables callback legacy?] + :or {id (internals/generate-id)} + :as event-payload}] + (let [query (str "query " (string/replace query #"^query\s?" "")) websocket-supported? (contains? (get-in db [:ws :supported-operations]) :query)] (cond - (or (get-in db [:http :requests query-id]) - (get-in db [:subscriptions query-id])) + (or (get-in db [:http :requests id]) + (get-in db [:subscriptions id])) {} ;; duplicate in-flight query (and websocket-supported? (get-in db [:ws :ready?])) - {:db (assoc-in db [:subscriptions query-id] {:callback callback-event}) + {:db (assoc-in db [:subscriptions id] {:callback callback + :legacy? legacy?}) ::internals/send-ws [(get-in db [:ws :connection]) - {:id query-id + {:id id :type "start" :payload {:query query :variables variables}}]} (and websocket-supported? (:ws db)) - {:db (update-in db [:ws :queue] conj dispatchable-event)} + {:db (update-in db [:ws :queue] conj [::query event-payload])} :else - {:db (assoc-in db [:http :requests query-id] {:callback callback-event}) - ::internals/send-http [instance-name - query-id - (get-in db [:http :url]) - {:request (get-in db [:http :impl]) - :payload {:query query - :variables variables}}]})))) + {:db (assoc-in db [:http :requests id] {:callback callback}) + ::internals/send-http {:url (get-in db [:http :url]) + :request (get-in db [:http :impl]) + :payload {:query query + :variables variables} + :event (assoc event-payload :id id)}})))) (defn query - "Execute a GraphQL query. The arguments are: + "Execute a GraphQL query. See ::spec/query for the arguments" + [opts] + (re-frame/dispatch [::query (update opts :callback (fn [f] [::internals/callback {:callback-fn f}]))])) - [instance-name query-string variables callback] - - If the optional `instance-name` is not provided, the default instance is - used. The callback function will receive the result of the query as its - sole argument." - [& args] - (let [callback-fn (last args)] - (re-frame/dispatch (into [::query] (conj (vec (butlast args)) [::internals/callback callback-fn]))))) +(s/fdef query :args (s/cat :opts ::spec/query)) #?(:clj (def - ^{:doc "Executes a query synchronously. The arguments are: - - [instance-name query-string variables timeout] - - The `instance-name` and `timeout` are optional. The `timeout` is - specified in milliseconds."} + ^{:doc "Executes a query synchronously. + Options are per `query` with an additional optional `:timeout` specified in milliseconds."} query-sync (partial internals/sync-wrapper query))) (re-frame/reg-event-fx ::abort - interceptors - (fn [{:keys [db]} [query-id]] + (interceptors ::spec/abort) + (fn [{:keys [db]} {:keys [id]}] (merge - {:db (-> db - (update :subscriptions dissoc query-id) - (update-in [:http :requests] dissoc query-id))} - (when-let [abort-fn (get-in db [:http :requests query-id :abort])] + {:db (-> db + (update :subscriptions dissoc id) + (update-in [:http :requests] dissoc id))} + (when-let [abort-fn (get-in db [:http :requests id :abort])] {::internals/call-abort abort-fn}) ))) (defn abort - ([query-id] (abort default-instance-name query-id)) - ([instance-name query-id] - (re-frame/dispatch [::abort instance-name query-id]))) + "Abort a pending query or mutation. See ::spec/abort for the arguments" + [opts] + (re-frame/dispatch [::abort opts])) + +(s/fdef abort :args (s/cat :opts ::spec/abort)) + +;; subscriptions (re-frame/reg-event-fx ::subscribe - interceptors - (fn [{:keys [db instance-name dispatchable-event]} [subscription-id query variables callback-event]] + (interceptors ::spec/subscribe) + (fn [{:keys [db]} {:keys [id query variables callback instance-id legacy?] :as event}] (cond - (get-in db [:subscriptions (name subscription-id) :active?]) + (get-in db [:subscriptions (name id) :active?]) {} ;; duplicate subscription (get-in db [:ws :ready?]) - {:db (assoc-in db [:subscriptions (name subscription-id)] {:callback callback-event - :event dispatchable-event - :active? true}) + {:db (assoc-in db [:subscriptions (name id)] {:callback callback + :event [::subscribe event] + :active? true + :legacy? legacy?}) ::internals/send-ws [(get-in db [:ws :connection]) - {:id (name subscription-id) + {:id (name id) :type "start" :payload {:query (str "subscription " (string/replace query #"^subscription\s?" "")) :variables variables}}]} (:ws db) - {:db (update-in db [:ws :queue] conj dispatchable-event)} + {:db (update-in db [:ws :queue] conj [::subscribe event])} :else (log/error (str - "Error creating subscription " subscription-id - " on instance " instance-name + "Error creating subscription " id + " on instance " instance-id ": Websocket is not enabled, subscriptions are not possible. Please check your re-graph configuration"))))) (defn subscribe - ([subscription-id query variables callback-fn] (subscribe default-instance-name subscription-id query variables callback-fn)) - ([instance-name subscription-id query variables callback-fn] - (re-frame/dispatch [::subscribe instance-name subscription-id query variables [::internals/callback callback-fn]]))) + "Create a GraphQL subscription. See ::spec/subscribe for the arguments" + [opts] + (re-frame/dispatch [::subscribe (update opts :callback (fn [f] [::internals/callback {:callback-fn f}]))])) + +(s/fdef subscribe :args (s/cat :opts ::spec/subscribe)) (re-frame/reg-event-fx ::unsubscribe - interceptors - (fn [{:keys [db instance-name]} [subscription-id]] + (interceptors ::spec/unsubscribe) + (fn [{:keys [db]} {:keys [id] :as event}] (if (get-in db [:ws :ready?]) - {:db (update db :subscriptions dissoc (name subscription-id)) + {:db (update db :subscriptions dissoc (name id)) ::internals/send-ws [(get-in db [:ws :connection]) - {:id (name subscription-id) + {:id (name id) :type "stop"}]} - {:db (update-in db [:ws :queue] conj [::unsubscribe instance-name subscription-id])}))) + {:db (update-in db [:ws :queue] conj [::unsubscribe event])}))) (defn unsubscribe - ([subscription-id] (unsubscribe default-instance-name subscription-id)) - ([instance-name subscription-id] - (re-frame/dispatch [::unsubscribe instance-name subscription-id]))) + "Cancel an existing GraphQL subscription. See ::spec/unsubscribe for the arguments" + [opts] + (re-frame/dispatch [::unsubscribe opts])) -(re-frame/reg-event-fx - ::re-init - [re-frame/trim-v internals/re-graph-instance] - (fn [{:keys [db instance-name]} [opts]] - (let [new-db (internals/deep-merge db opts)] - (merge {:db new-db} - (when (get-in new-db [:ws :ready?]) - {:dispatch [::internals/connection-init instance-name]}))))) +(s/fdef unsubscribe :args (s/cat :opts ::spec/unsubscribe)) -(defn re-init - ([opts] (re-init default-instance-name opts)) - ([instance-name opts] - (re-frame/dispatch [::re-init instance-name opts]))) +;; re-graph lifecycle (re-frame/reg-event-fx ::init - (fn [{:keys [db]} [_ instance-name opts]] - (let [[instance-name opts] (cond - (and (nil? instance-name) (nil? opts)) - [default-instance-name {}] + [re-frame/unwrap (internals/assert-spec ::spec/init)] + (fn [{:keys [db]} {:keys [instance-id] + :or {instance-id internals/default-instance-id} + :as opts}] + (let [{:keys [ws] :as opts} + (merge opts + (internals/ws-options opts) + (internals/http-options opts))] + (merge + {:db (assoc-in db [:re-graph instance-id] opts)} + (when ws + {::internals/connect-ws [instance-id ws]}))))) - (map? instance-name) - [default-instance-name instance-name] +(defn init + "Initialise an instance of re-graph. See ::spec/init for the arguments" + [opts] + (re-frame/dispatch [::init opts])) - (nil? instance-name) - [default-instance-name opts] +(s/fdef init :args (s/cat :opts ::spec/init)) - :else - [instance-name opts]) - ws-options (internals/ws-options opts) - http-options (internals/http-options opts)] +(re-frame/reg-event-fx + ::re-init + [re-frame/unwrap internals/select-instance (internals/assert-spec ::spec/re-init)] + (fn [{:keys [db]} opts] + (let [new-db (internals/deep-merge db opts)] + (merge {:db new-db} + (when (get-in new-db [:ws :ready?]) + {:dispatch [::internals/connection-init opts]}))))) - (merge - {:db (assoc-in db [:re-graph instance-name] - (merge ws-options http-options))} - (when ws-options - {::internals/connect-ws [instance-name ws-options]}))))) +(defn re-init + "Re-initialise an instance of re-graph. See ::spec/re-init for the arguments" + [opts] + (re-frame/dispatch [::re-init opts])) + +(s/fdef re-init :args (s/cat :opts ::spec/re-init)) (re-frame/reg-event-fx ::destroy - interceptors - (fn [{:keys [db instance-name]} _] - (if-let [subscription-ids (not-empty (-> db :subscriptions keys))] - {:dispatch-n (for [subscription-id subscription-ids] - [::unsubscribe instance-name subscription-id]) - :dispatch [::destroy instance-name]} + (interceptors ::spec/destroy) + (fn [{:keys [db]} {:keys [instance-id]}] + (if-let [ids (not-empty (-> db :subscriptions keys))] + {:dispatch-n (for [id ids] + [::unsubscribe {:instance-id instance-id + :id id}]) + :dispatch [::destroy {:instance-id instance-id}]} (merge {:db (assoc db :destroyed? true)} (when-let [ws (get-in db [:ws :connection])] {::internals/disconnect-ws [ws]}))))) -(defn init - ([opts] (init default-instance-name opts)) - ([instance-name opts] - (re-frame/dispatch [::init instance-name opts]))) - (defn destroy - ([] (destroy default-instance-name)) - ([instance-name] - (re-frame/dispatch [::destroy instance-name]))) + "Destroy an instance of re-graph. See ::spec/destroy for the arguments" + [opts] + (re-frame/dispatch [::destroy opts])) + +(s/fdef destroy :args (s/cat :opts ::spec/destroy)) diff --git a/src/re_graph/core_deprecated.cljc b/src/re_graph/core_deprecated.cljc new file mode 100644 index 0000000..32a168f --- /dev/null +++ b/src/re_graph/core_deprecated.cljc @@ -0,0 +1,238 @@ +(ns re-graph.core-deprecated + "DEPRECATED: Use re-graph.core" + (:require [re-frame.core :as re-frame] + [re-graph.internals :as internals + :refer [default-instance-id]] + [re-graph.core :as core] + [re-graph.logging :as log] + [re-frame.std-interceptors :as rfi] + [re-frame.interceptor :refer [->interceptor get-coeffect assoc-coeffect]])) + +#?(:clj + (defn sync-wrapper + "Wraps the given function to allow the GraphQL result to be returned + synchronously. Will return a GraphQL error response if no response is + received before the timeout (default 3000ms) expires. Will throw if the + call returns an exception." + [f & args] + (let [timeout (when (int? (last args)) (last args)) + timeout' (or timeout 3000) + p (promise) + callback (fn [result] (deliver p result)) + args' (conj (vec (if timeout (butlast args) args)) + callback)] + (apply f args') + + ;; explicit timeout to avoid unreliable aborts from underlying implementations + (let [result (deref p timeout' ::timeout)] + (if (= ::timeout result) + {:errors [{:message "re-graph did not receive response from server" + :timeout timeout' + :args args}]} + result))))) + +(defn- ensure-id [event-name trimmed-event] + (if (contains? #{::query ::mutate} event-name) + (if (= 3 (count trimmed-event)) ;; query, variables, callback + (vec (cons (internals/generate-id) trimmed-event)) + trimmed-event) + trimmed-event)) + +(def re-graph-instance + (->interceptor + :id ::instance + :before (fn [ctx] + (let [re-graph (:re-graph (get-coeffect ctx :db)) + event (get-coeffect ctx :event) + provided-instance-id (first event) + instance-id (if (contains? re-graph provided-instance-id) provided-instance-id default-instance-id) + instance (get re-graph instance-id) + event-name (first (get-coeffect ctx :original-event)) + trimmed-event (->> (if (= provided-instance-id instance-id) + (subvec event 1) + event) + (ensure-id event-name))] + + (cond + (:destroyed? instance) + ctx + + instance + (-> ctx + (assoc-coeffect :instance-id instance-id) + (assoc-coeffect :dispatchable-event (into [event-name instance-id] trimmed-event)) + (internals/cons-interceptor (rfi/path :re-graph instance-id)) + (assoc-coeffect :event trimmed-event)) + + :else + (do (log/error "No default instance of re-graph found but no valid instance name was provided. Valid instance names are:" (keys re-graph) + "but was provided with" provided-instance-id + "handling event" event-name) + ctx)))))) + +(def interceptors + [re-frame/trim-v re-graph-instance]) + +(re-frame/reg-event-fx + ::mutate + interceptors + (fn [{:keys [instance-id]} [id query variables callback]] + {:dispatch [::core/mutate {:instance-id instance-id + :id id + :query query + :variables variables + :callback callback + :legacy? true}]})) + +(defn mutate + "Execute a GraphQL mutation. The arguments are: + + [instance-id query-string variables callback] + + If the optional `instance-id` is not provided, the default instance is + used. The callback function will receive the result of the mutation as its + sole argument." + [& args] + (let [callback-fn (last args)] + (re-frame/dispatch (into [::mutate] (conj (vec (butlast args)) [::internals/callback {:callback-fn callback-fn}]))))) + +#?(:clj + (def + ^{:doc "Executes a mutation synchronously. The arguments are: + + [instance-id query-string variables timeout] + + The `instance-id` and `timeout` are optional. The `timeout` is + specified in milliseconds."} + mutate-sync + (partial sync-wrapper mutate))) + +(re-frame/reg-event-fx + ::query + interceptors + (fn [{:keys [instance-id]} [id query variables callback]] + {:dispatch [::core/query {:instance-id instance-id + :id id + :query query + :variables variables + :callback callback + :legacy? true}]})) + +(defn query + "Execute a GraphQL query. The arguments are: + + [instance-id query-string variables callback] + + If the optional `instance-id` is not provided, the default instance is + used. The callback function will receive the result of the query as its + sole argument." + [& args] + (let [callback-fn (last args)] + (re-frame/dispatch (into [::query] (conj (vec (butlast args)) [::internals/callback {:callback-fn callback-fn}]))))) + +#?(:clj + (def + ^{:doc "Executes a query synchronously. The arguments are: + + [instance-id query-string variables timeout] + + The `instance-id` and `timeout` are optional. The `timeout` is + specified in milliseconds."} + query-sync + (partial sync-wrapper query))) + +(re-frame/reg-event-fx + ::abort + interceptors + (fn [{:keys [instance-id]} [id]] + {:dispatch [::core/abort {:instance-id instance-id + :id id + :legacy? true}]})) + +(defn abort + ([id] (abort default-instance-id id)) + ([instance-id id] + (re-frame/dispatch [::abort instance-id id]))) + +(re-frame/reg-event-fx + ::subscribe + interceptors + (fn [{:keys [instance-id]} [id query variables callback]] + {:dispatch [::core/subscribe {:instance-id instance-id + :id id + :query query + :variables variables + :callback callback + :legacy? true}]})) + +(defn subscribe + ([id query variables callback-fn] + (subscribe default-instance-id id query variables callback-fn)) + ([instance-id id query variables callback-fn] + (re-frame/dispatch [::subscribe instance-id id query variables [::internals/callback {:callback-fn callback-fn}]]))) + +(re-frame/reg-event-fx + ::unsubscribe + interceptors + (fn [{:keys [instance-id]} [id]] + {:dispatch [::core/unsubscribe {:instance-id instance-id + :id id + :legacy? true}]})) + +(defn unsubscribe + ([id] (unsubscribe default-instance-id id)) + ([instance-id id] + (re-frame/dispatch [::unsubscribe instance-id id]))) + +(re-frame/reg-event-fx + ::re-init + [re-frame/trim-v re-graph-instance] + (fn [{:keys [instance-id]} [opts]] + {:dispatch [::core/re-init (assoc opts :instance-id instance-id + :legacy? true)]})) + +(defn re-init + ([opts] (re-init default-instance-id opts)) + ([instance-id opts] + (re-frame/dispatch [::re-init instance-id opts]))) + +(re-frame/reg-event-fx + ::init + (fn [_ [_ instance-id opts]] + (let [[instance-id opts] (cond + (and (nil? instance-id) (nil? opts)) + [default-instance-id {}] + + (map? instance-id) + [default-instance-id instance-id] + + (nil? instance-id) + [default-instance-id opts] + + :else + [instance-id opts]) + ws-options (internals/ws-options opts) + http-options (internals/http-options opts)] + + {:dispatch [::core/init (merge {:instance-id instance-id + :legacy? true} + opts + ws-options + http-options)]}))) + +(re-frame/reg-event-fx + ::destroy + interceptors + (fn [{:keys [instance-id]} _] + {:dispatch [::core/destroy {:instance-id instance-id + :legacy? true}]})) + +(defn init + ([opts] (init default-instance-id opts)) + ([instance-id opts] + (re-frame/dispatch [::init instance-id opts]))) + +(defn destroy + ([] (destroy default-instance-id)) + ([instance-id] + (re-frame/dispatch [::destroy instance-id]))) diff --git a/src/re_graph/internals.cljc b/src/re_graph/internals.cljc index 2d14719..f021c8c 100644 --- a/src/re_graph/internals.cljc +++ b/src/re_graph/internals.cljc @@ -1,9 +1,11 @@ (ns re-graph.internals (:require [re-frame.core :as re-frame] - [re-frame.interceptor :refer [->interceptor get-coeffect assoc-coeffect update-coeffect get-effect assoc-effect]] + [re-frame.interceptor :refer [->interceptor get-coeffect update-coeffect get-effect assoc-effect]] [re-frame.std-interceptors :as rfi] - [re-frame.interop :refer [empty-queue]] [re-graph.logging :as log] + [re-frame.interop :refer [empty-queue]] + [clojure.spec.alpha :as s] + [re-graph.spec :as spec] #?@(:cljs [[cljs-http.client :as http] [cljs-http.core :as http-core]] :clj [[re-graph.interop :as interop]]) @@ -12,9 +14,9 @@ #?(:cljs (:require-macros [cljs.core.async.macros :refer [go]])) #?(:clj (:import [java.util UUID]))) -(def default-instance-name ::default) +(def default-instance-id ::default) -(defn- cons-interceptor [ctx interceptor] +(defn cons-interceptor [ctx interceptor] (update ctx :queue #(into (into empty-queue [interceptor]) %))) (defn- encode [obj] @@ -27,17 +29,10 @@ (js->clj :keywordize-keys true)) :clj (json/decode m keyword))) -(defn generate-query-id [] +(defn generate-id [] #?(:cljs (.substr (.toString (Math/random) 36) 2 8) :clj (str (UUID/randomUUID)))) -(defn- ensure-query-id [event-name trimmed-event] - (if (contains? #{:re-graph.core/query :re-graph.core/mutate} event-name) - (if (= 3 (count trimmed-event)) ;; query, variables, callback-event - (vec (cons (generate-query-id) trimmed-event)) - trimmed-event) - trimmed-event)) - (defn deep-merge [a b] (merge-with (fn [a b] @@ -73,52 +68,31 @@ http-impl (assoc-in [:http :impl] http-impl) ws-impl (assoc-in [:ws :impl] ws-impl)))))))) -(def re-graph-instance +(def select-instance (->interceptor - :id ::instance + :id ::select-instance :before (fn [ctx] - (let [re-graph (:re-graph (get-coeffect ctx :db)) - event (get-coeffect ctx :event) - provided-instance-name (first event) - instance-name (if (contains? re-graph provided-instance-name) provided-instance-name default-instance-name) - instance (get re-graph instance-name) - event-name (first (get-coeffect ctx :original-event)) - trimmed-event (->> (if (= provided-instance-name instance-name) - (subvec event 1) - event) - (ensure-query-id event-name))] - - (cond - (:destroyed? instance) - (do (log/error "It looks like the re-graph instance has been destroyed, so cannot handle event" event-name) - ctx) - - instance + (let [re-graph (:re-graph (get-coeffect ctx :db)) + instance-id (:instance-id (get-coeffect ctx :event) default-instance-id) + instance (get re-graph instance-id)] + (if instance (-> ctx - (assoc-coeffect :instance-name instance-name) - (assoc-coeffect :dispatchable-event (into [event-name instance-name] trimmed-event)) - (cons-interceptor (rfi/path :re-graph instance-name)) - (assoc-coeffect :event trimmed-event)) - - (and provided-instance-name (seq (keys re-graph))) - (do (log/error "No default instance of re-graph found but no valid instance name was provided. Valid instance names are:" (keys re-graph) - "but was provided with" provided-instance-name - "handling event" event-name) - ctx) - - (nil? re-graph) - (do (log/error "It looks like re-graph has not been initialised yet, so cannot handle event" event-name) - ctx) - - :else - (do (log/error "No re-graph valid re-graph instance found. Valid instance ids are:" (keys re-graph) - "but was provided with instance id" provided-instance-name - "handling event" event-name - "Have you initialised re-graph properly?") + (update-coeffect :event assoc :instance-id instance-id) + (cons-interceptor (rfi/path :re-graph instance-id))) + (do (log/error "No re-graph instance found for instance-id" instance-id " - have you initialised re-graph properly?" + "Handling event" (get-coeffect ctx :original-event)) ctx)))))) -(def interceptors - [re-frame/trim-v re-graph-instance instantiate-impl]) +(defn assert-spec [spec] + (->interceptor + :id ::assert-spec + :before (fn [ctx] + (s/assert spec (get-coeffect ctx :event)) + ctx))) + +(defn interceptors + ([spec] (into (interceptors) [(assert-spec spec)])) + ([] [re-frame/unwrap select-instance instantiate-impl])) (defn- valid-graphql-errors? "Validates that response has a valid GraphQL errors map" @@ -144,13 +118,16 @@ (re-frame/reg-event-fx ::http-complete - interceptors - (fn [{:keys [db]} [query-id payload]] - (let [callback-event (get-in db [:http :requests query-id :callback])] + (interceptors) + (fn [{:keys [db]} {:keys [legacy? id response]}] + (let [callback (get-in db [:http :requests id :callback])] {:db (-> db - (update :subscriptions dissoc query-id) - (update-in [:http :requests] dissoc query-id)) - :dispatch (conj callback-event payload)}))) + (update :subscriptions dissoc id) + (update-in [:http :requests] dissoc id)) + :dispatch (if (and legacy? ;; enforce legacy behaviour for deprecated api + (not= ::callback (first callback))) + (conj callback response) + (update callback 1 assoc :response response))}))) (re-frame/reg-fx ::call-abort @@ -159,41 +136,37 @@ (re-frame/reg-event-db ::register-abort - interceptors - (fn [db [query-id abort-fn]] - (assoc-in db [:http :requests query-id :abort] abort-fn))) + (interceptors) + (fn [db {:keys [id abort-fn]}] + (assoc-in db [:http :requests id :abort] abort-fn))) (def unexceptional-status? #{200 201 202 203 204 205 206 207 300 301 302 303 304 307}) (re-frame/reg-fx ::send-http - (fn [[instance-name query-id http-url {:keys [request payload]}]] - #?(:cljs (let [response-chan (http/post http-url (assoc request :json-params payload))] - (re-frame/dispatch [::register-abort instance-name query-id #(http-core/abort! response-chan)]) + (fn [{:keys [event url request payload]}] + #?(:cljs (let [response-chan (http/post url (assoc request :json-params payload))] + (re-frame/dispatch [::register-abort (assoc event :abort-fn #(http-core/abort! response-chan))]) (go (let [{:keys [status body error-code]} (a/> db :subscriptions vals (map :event))) queue (get-in db [:ws :queue]) - to-send (concat [[::connection-init instance-name]] subscriptions queue)] - {:dispatch-n to-send})))) + to-send (concat [[::connection-init {:instance-id instance-id}]] + subscriptions + queue)] + {:dispatch-n (vec to-send)})))) (defn- deactivate-subscriptions [subscriptions] (reduce-kv (fn [subs sub-id sub] @@ -261,8 +240,8 @@ (re-frame/reg-event-fx ::on-ws-close - interceptors - (fn [{:keys [db instance-name]} _] + (interceptors) + (fn [{:keys [db]} {:keys [instance-id]}] (merge {:db (let [new-db (-> db (assoc-in [:ws :ready?] false) @@ -270,64 +249,70 @@ new-db)} (when-let [reconnect-timeout (get-in db [:ws :reconnect-timeout])] {:dispatch-later [{:ms reconnect-timeout - :dispatch [::reconnect-ws instance-name]}]})))) + :dispatch [::reconnect-ws {:instance-id instance-id}]}]})))) -(defn- on-ws-message [instance-name] +(defn- on-ws-message [instance-id] (fn [m] (let [{:keys [type id payload]} (message->data m)] (condp = type "data" - (re-frame/dispatch [::on-ws-data instance-name id payload]) + (re-frame/dispatch [::on-ws-data {:instance-id instance-id + :id id + :payload payload}]) "complete" - (re-frame/dispatch [::on-ws-complete instance-name id]) + (re-frame/dispatch [::on-ws-complete {:instance-id instance-id + :id id}]) "error" - (re-frame/dispatch [::on-ws-data instance-name id {:errors payload}]) + (re-frame/dispatch [::on-ws-data {:instance-id instance-id + :id id + :payload {:errors payload}}]) - (log/debug "Ignoring graphql-ws event " instance-name " - " type))))) + (log/debug "Ignoring graphql-ws event " instance-id " - " type))))) (defn- on-open - ([instance-name] + ([instance-id] (fn [websocket] - ((on-open instance-name websocket)))) - ([instance-name websocket] + ((on-open instance-id websocket)))) + ([instance-id websocket] (fn [] - (log/info "opened ws" websocket) - (re-frame/dispatch [::on-ws-open instance-name websocket])))) + (log/info "opened ws" instance-id websocket) + (re-frame/dispatch [::on-ws-open {:instance-id instance-id + :websocket websocket}])))) -(defn- on-close [instance-name] +(defn- on-close [instance-id] (fn [& _args] - (re-frame/dispatch [::on-ws-close instance-name]))) + (re-frame/dispatch [::on-ws-close {:instance-id instance-id}]))) -(defn- on-error [instance-name] +(defn- on-error [instance-id] (fn [e] - (log/warn "GraphQL websocket error" instance-name e))) + (log/warn "GraphQL websocket error" instance-id e))) (re-frame/reg-event-fx ::reconnect-ws - interceptors - (fn [{:keys [db instance-name]} _] + (interceptors) + (fn [{:keys [db]} {:keys [instance-id]}] (when-not (get-in db [:ws :ready?]) - {::connect-ws [instance-name db]}))) + {::connect-ws [instance-id (:ws db)]}))) (re-frame/reg-fx ::connect-ws - (fn [[instance-name {{:keys [url sub-protocol #?(:clj impl)]} :ws}]] + (fn [[instance-id {:keys [url sub-protocol #?(:clj impl)]}]] #?(:cljs (let [ws (cond (nil? sub-protocol) (js/WebSocket. url) :else ;; non-nil sub protocol (js/WebSocket. url sub-protocol))] - (aset ws "onmessage" (on-ws-message instance-name)) - (aset ws "onopen" (on-open instance-name ws)) - (aset ws "onclose" (on-close instance-name)) - (aset ws "onerror" (on-error instance-name))) + (aset ws "onmessage" (on-ws-message instance-id)) + (aset ws "onopen" (on-open instance-id ws)) + (aset ws "onclose" (on-close instance-id)) + (aset ws "onerror" (on-error instance-id))) :clj (interop/create-ws url (merge (build-impl impl) - {:on-open (on-open instance-name) - :on-message (on-ws-message instance-name) - :on-close (on-close instance-name) - :on-error (on-error instance-name) + {:on-open (on-open instance-id) + :on-message (on-ws-message instance-id) + :on-close (on-close instance-id) + :on-error (on-error instance-id) :subprotocols [sub-protocol]}))))) (re-frame/reg-fx @@ -388,19 +373,19 @@ synchronously. Will return a GraphQL error response if no response is received before the timeout (default 3000ms) expires. Will throw if the call returns an exception." - [f & args] - (let [timeout (when (int? (last args)) (last args)) - timeout' (or timeout 3000) - p (promise) - callback (fn [result] (deliver p result)) - args' (conj (vec (if timeout (butlast args) args)) - callback)] - (apply f args') + [f {:keys [timeout] + :or {timeout 3000} + :as opts}] + (let [p (promise) + callback (fn [result] (deliver p result))] + (f (assoc opts :callback callback)) ;; explicit timeout to avoid unreliable aborts from underlying implementations - (let [result (deref p timeout' ::timeout)] + (let [result (deref p timeout ::timeout)] (if (= ::timeout result) {:errors [{:message "re-graph did not receive response from server" - :timeout timeout' - :args args}]} + :opts opts}]} result))))) + +#?(:clj + (s/fdef sync-wrapper :args (s/cat :fn fn? :opts ::spec/sync-operation))) diff --git a/src/re_graph/spec.cljc b/src/re_graph/spec.cljc new file mode 100644 index 0000000..0ebe704 --- /dev/null +++ b/src/re_graph/spec.cljc @@ -0,0 +1,73 @@ +(ns re-graph.spec + (:require [clojure.spec.alpha :as s])) + +;; primitives + +(s/def ::id some?) + +(s/def ::instance-id ::id) +(s/def ::query-id ::id) + +(s/def :payload/query string?) +(s/def ::variables map?) +(s/def ::callback (s/or :event vector? :fn fn?)) + +(s/def ::timeout int?) + +;; queries and mutations + +(s/def ::query (s/keys :req-un [:payload/query + ::callback] + :opt-un [::variables + ::id + ::instance-id])) + +(s/def ::mutate ::query) + +(s/def ::abort (s/keys :req-un [::id] + :opt-un [::instance-id])) + +(s/def ::sync-operation (s/keys :opt-un [::timeout])) + +;; subscriptions + +(s/def ::subscribe (s/keys :req-un [:payload/query + ::id + ::callback] + :opt-un [::variables + ::instance-id])) + +(s/def ::unsubscribe (s/keys :req-un [::id] + :opt-un [::instance-id])) + +;; re-graph lifecycle + +(s/def ::url string?) +(s/def ::sub-protocol string?) +(s/def ::reconnect-timeout int?) +(s/def ::resume-subscriptions? boolean?) +(s/def ::connection-init-payload (s/nilable map?)) +(s/def ::supported-operations (s/coll-of #{:query :mutate :subscribe} :kind set? :distinct true :into #{})) +(s/def ::impl (s/or :map map? :fn fn?)) + +(s/def ::ws (s/nilable + (s/keys :opt-un [::url + ::sub-protocol + ::reconnect-timeout + ::resume-subscriptions? + ::connection-init-payload + ::supported-operations + ::impl]))) + +(s/def ::http (s/nilable + (s/keys :opt-un [::url + ::supported-operations + ::impl]))) + +(s/def ::init (s/keys :opt-un [::ws + ::http + ::instance-id])) + +(s/def ::re-init ::init) + +(s/def ::destroy (s/keys :opt-un [::instance-id])) diff --git a/test/re_graph/all_tests.cljs b/test/re_graph/all_tests.cljs index b4b1ba4..8cd8f4c 100644 --- a/test/re_graph/all_tests.cljs +++ b/test/re_graph/all_tests.cljs @@ -1,3 +1,5 @@ (ns re-graph.all-tests (:require [re-graph.core-test] - [re-graph.integration-test])) + [re-graph.core-deprecated-test] + [re-graph.integration-test] + [re-graph.deprecated-integration-test])) diff --git a/test/re_graph/core_deprecated_test.cljc b/test/re_graph/core_deprecated_test.cljc new file mode 100644 index 0000000..f841d28 --- /dev/null +++ b/test/re_graph/core_deprecated_test.cljc @@ -0,0 +1,937 @@ +(ns re-graph.core-deprecated-test + (:require [re-graph.core-deprecated :as re-graph] + [re-graph.core :as re-graph-core] + [re-graph.internals :as internals :refer [default-instance-id]] + [re-frame.core :as re-frame] + [re-frame.db :refer [app-db]] + [day8.re-frame.test :refer [run-test-sync run-test-async wait-for] + :refer-macros [run-test-sync run-test-async wait-for]] + [clojure.test :refer [deftest is testing] + :refer-macros [deftest is testing]] + [clojure.spec.alpha :as s] + [clojure.spec.test.alpha :as stest] + #?@(:clj [[cheshire.core :as json] + [hato.client :as hato] + [clj-http.client :as clj-http]]))) + +(stest/instrument) +(s/check-asserts true) + +(def on-ws-message @#'internals/on-ws-message) +(def on-open @#'internals/on-open) +(def on-close @#'internals/on-close) +(def insert-http-status @#'internals/insert-http-status) + +(defn- data->message [d] + #?(:cljs (clj->js {:data (js/JSON.stringify (clj->js d))}) + :clj (json/encode d))) + +(defn- install-websocket-stub! [] + (re-frame/reg-fx + ::internals/connect-ws + (fn [[instance-id _options]] + ((on-open instance-id ::websocket-connection))))) + +(defn- prepend-instance-id [instance-id [event-name & args :as event]] + (if instance-id + (into [event-name instance-id] args) + event)) + +(defn- dispatch-to-instance [instance-id event] + (re-frame/dispatch (prepend-instance-id instance-id event))) + +(defn- init [instance-id opts] + (if (nil? instance-id) + (re-frame/dispatch [::re-graph/init opts]) + (re-frame/dispatch [::re-graph/init instance-id opts]))) + +(defn- run-subscription-test [instance-id] + (let [dispatch (partial dispatch-to-instance instance-id) + db-instance #(get-in @app-db [:re-graph (or instance-id default-instance-id)]) + on-ws-message (on-ws-message (or instance-id default-instance-id))] + (run-test-sync + (install-websocket-stub!) + (init instance-id {:ws {:url "ws://socket.rocket" + :connection-init-payload nil}}) + + (let [expected-subscription-payload {:id "my-sub" + :type "start" + :payload {:query "subscription { things { id } }" + :variables {:some "variable"}}} + expected-unsubscription-payload {:id "my-sub" + :type "stop"}] + + (testing "Subscriptions can be registered" + + (re-frame/reg-fx + ::internals/send-ws + (fn [[ws payload]] + (is (= ::websocket-connection ws)) + (is (= expected-subscription-payload + payload)))) + + (dispatch [::re-graph/subscribe :my-sub "{ things { id } }" {:some "variable"} [::on-thing]]) + + (is (= [::on-thing] + (get-in (db-instance) [:subscriptions "my-sub" :callback]))) + + (testing "and deduplicated" + (re-frame/reg-fx + ::internals/send-ws + (fn [_] + (is false "Should not have sent a websocket message for an existing subscription"))) + + (dispatch [::re-graph/subscribe :my-sub "{ things { id } }" {:some "variable"} [::on-thing]])) + + (testing "messages from the WS are sent to the callback" + + (let [expected-response-payload {:data {:things [{:id 1} {:id 2}]}}] + (re-frame/reg-event-db + ::on-thing + (fn [db [_ payload]] + (assoc db ::thing payload))) + + (on-ws-message (data->message {:type "data" + :id "my-sub" + :payload expected-response-payload})) + + (is (= expected-response-payload + (::thing @app-db))))) + + (testing "errors from the WS are sent to the callback" + + (let [expected-response-payload {:errors {:message "Something went wrong"}}] + (re-frame/reg-event-db + ::on-thing + (fn [db [_ payload]] + (assoc db ::thing payload))) + + (on-ws-message (data->message {:type "error" + :id "my-sub" + :payload (:errors expected-response-payload)})) + + (is (= expected-response-payload + (::thing @app-db))))) + + (testing "and unregistered" + (re-frame/reg-fx + ::internals/send-ws + (fn [[ws payload]] + (is (= ::websocket-connection ws)) + (is (= expected-unsubscription-payload + payload)))) + + (dispatch [::re-graph/unsubscribe :my-sub]) + + (is (nil? (get-in (db-instance) [:subscriptions "my-sub"]))))))))) + +(deftest subscription-test + (run-subscription-test nil)) + +(deftest named-subscription-test + (run-subscription-test :service-a)) + +(defn- run-websocket-lifecycle-test [instance-id] + (let [dispatch (partial dispatch-to-instance instance-id) + db-instance #(get-in @app-db [:re-graph (or instance-id default-instance-id)]) + on-open (partial on-open (or instance-id default-instance-id))] + (run-test-sync + + (re-frame/reg-fx + ::internals/connect-ws + (constantly nil)) + + (let [init-payload {:token "abc"} + expected-subscription-payload {:id "my-sub" + :type "start" + :payload {:query "subscription { things { id } }" + :variables {:some "variable"}}}] + + (init instance-id {:ws {:url "ws://socket.rocket" + :connection-init-payload init-payload}}) + + (testing "messages are queued when websocket isn't ready" + + (dispatch [::re-graph/subscribe :my-sub "{ things { id } }" {:some "variable"} [::on-thing]]) + (dispatch [::re-graph/query "{ more_things { id } }" {:some "other-variable"} [::on-thing]]) + + (is (= 2 (count (get-in (db-instance) [:ws :queue])))) + + (testing "and sent when websocket opens" + + (let [ws-messages (atom [])] + (re-frame/reg-fx + ::internals/send-ws + (fn [[ws payload]] + (swap! ws-messages conj [ws payload]))) + + ((on-open ::websocket-connection)) + + (testing "the connection init payload is sent first" + (is (= [::websocket-connection + {:type "connection_init" + :payload init-payload}] + (first @ws-messages)))) + + (is (= [::websocket-connection expected-subscription-payload] + (second @ws-messages))) + + (is (= [::websocket-connection {:type "start", + :payload + {:query "query { more_things { id } }", + :variables {:some "other-variable"}}}] + ((juxt first (comp #(dissoc % :id) second)) (last @ws-messages))))) + + (is (empty? (get-in (db-instance) [:ws :queue])))))) + + (testing "when re-graph is destroyed" + (testing "the subscriptions are cancelled" + (re-frame/reg-fx + ::internals/send-ws + (fn [[ws payload]] + (is (= ::websocket-connection ws)) + (is (or (= {:id "my-sub" :type "stop"} + payload) + (= {:type "stop"} + (dissoc payload :id))))))) + + (testing "the websocket is closed" + (re-frame/reg-fx + ::internals/disconnect-ws + (fn [[ws]] + (is (= ::websocket-connection ws))))) + + (dispatch [::re-graph/destroy]) + + (testing "the re-graph state is set to destroyed" + (is (:destroyed? (db-instance)))))))) + +(deftest websocket-lifecycle-test + (run-websocket-lifecycle-test nil)) + +(deftest named-websocket-lifecycle-test + (run-websocket-lifecycle-test :service-a)) + +(defn- run-websocket-reconnection-test [instance-id] + (let [dispatch (partial dispatch-to-instance instance-id) + db-instance #(get-in @app-db [:re-graph (or instance-id default-instance-id)]) + on-close (on-close (or instance-id default-instance-id)) + sent-msgs (atom [])] + (run-test-async + (install-websocket-stub!) + + (re-frame/reg-fx + :dispatch-later + (fn [[{:keys [dispatch]}]] + (re-frame/dispatch dispatch))) + + (re-frame/reg-fx + ::internals/send-ws + (fn [[ws payload]] + (is (= ::websocket-connection ws)) + (is (or + (= "connection_init" (:type payload)) + (= {:id "my-sub" + :type "start" + :payload {:query "subscription { things { id } }" + :variables {:some "variable"}}} + payload))) + (swap! sent-msgs conj payload))) + + (testing "websocket reconnects when disconnected" + (init instance-id {:ws {:url "ws://socket.rocket" + :connection-init-payload {:token "abc"} + :reconnect-timeout 0}}) + + (let [{:keys [id + query + variables + callback] + :as subscription-params} + {:instance-id (or instance-id default-instance-id) + :id :my-sub + :query "{ things { id } }" + :variables {:some "variable"} + :callback [::on-thing] + :legacy? true}] + + + (wait-for + [::internals/on-ws-open] + (is (get-in (db-instance) [:ws :ready?])) + + ;; create a subscription and wait for it to be sent + (dispatch [::re-graph/subscribe id query variables callback]) + (wait-for [::re-graph-core/subscribe] + (on-close) + (wait-for + [::internals/on-ws-close] + (is (false? (get-in (db-instance) [:ws :ready?]))) + + (testing "websocket is reconnected" + (wait-for [::internals/on-ws-open] + (is (get-in (db-instance) [:ws :ready?])) + + (testing "subscriptions are resumed" + (wait-for + [(fn [event] + (= [::re-graph-core/subscribe subscription-params] event))] + (is (= 4 (count @sent-msgs))))))))))))))) + +(deftest websocket-reconnection-test + (run-websocket-reconnection-test nil)) + +(deftest named-websocket-reconnection-test + (run-websocket-reconnection-test :service-a)) + +(defn- run-websocket-query-test [instance-id] + (let [dispatch (partial dispatch-to-instance instance-id) + db-instance #(get-in @app-db [:re-graph (or instance-id default-instance-id)]) + on-ws-message (on-ws-message (or instance-id default-instance-id))] + (with-redefs [internals/generate-id (constantly "random-id")] + (run-test-sync + (install-websocket-stub!) + + (init instance-id {:ws {:url "ws://socket.rocket" + :connection-init-payload nil}}) + + (let [expected-query-payload {:id "random-id" + :type "start" + :payload {:query "query { things { id } }" + :variables {:some "variable"}}} + expected-response-payload {:data {:things [{:id 1} {:id 2}]}}] + + (testing "Queries can be made" + + (re-frame/reg-fx + ::internals/send-ws + (fn [[ws payload]] + (is (= ::websocket-connection ws)) + + (is (= expected-query-payload + payload)) + + (on-ws-message (data->message {:type "data" + :id (:id payload) + :payload expected-response-payload})))) + + (re-frame/reg-event-db + ::on-thing + (fn [db [_ payload]] + (assoc db ::thing payload))) + + (dispatch [::re-graph/query "{ things { id } }" {:some "variable"} [::on-thing]]) + + (testing "responses are sent to the callback" + (is (= expected-response-payload + (::thing @app-db)))) + + (on-ws-message (data->message {:type "complete" + :id "random-id"})) + + (testing "the callback is removed afterwards" + (is (nil? (get-in (db-instance) [:subscriptions "random-id"])))))))))) + +(deftest websocket-query-test + (run-websocket-query-test nil)) + +(deftest named-websocket-query-test + (run-websocket-query-test :service-a)) + +(deftest prefer-http-query-test + (run-test-sync + (install-websocket-stub!) + + (re-frame/dispatch [::re-graph/init {:ws {:url "ws://socket.rocket" + :connection-init-payload nil + :supported-operations #{:subscribe}} + :http {:url "http://foo.bar/graph-ql"}}]) + + (testing "Queries are sent via http because the websocket doesn't support them" + (let [http-called? (atom false)] + (re-frame/reg-fx + ::internals/send-http + (fn [_] + (reset! http-called? true))) + + (re-frame/dispatch [::re-graph/query "{ things { id } }" {:some "variable"} [::on-thing]]) + + (is @http-called?))))) + +(defn- dispatch-response [event payload] + (re-frame/dispatch [::internals/http-complete (assoc event :response payload)])) + +(defn- run-http-query-test [instance-id] + (let [dispatch (partial dispatch-to-instance instance-id)] + (run-test-sync + (let [expected-http-url "http://foo.bar/graph-ql"] + (init instance-id {:http {:url expected-http-url} + :ws nil}) + + (let [expected-query-payload {:query "query { things { id } }" + :variables {:some "variable"}} + expected-response-payload {:data {:things [{:id 1} {:id 2}]}}] + + (testing "Queries can be made" + + (re-frame/reg-fx + ::internals/send-http + (fn [{:keys [url payload event]}] + (is (= expected-query-payload + payload)) + + (is (= expected-http-url url)) + + (dispatch-response event expected-response-payload))) + + (re-frame/reg-event-db + ::on-thing + (fn [db [_ payload]] + (assoc db ::thing payload))) + + (dispatch [::re-graph/query "{ things { id } }" {:some "variable"} [::on-thing]]) + + (testing "responses are sent to the callback" + (is (= expected-response-payload + (::thing @app-db))))) + + (testing "In flight queries are deduplicated" + (let [id :abc-123] + (re-frame/reg-fx + ::internals/send-http + (fn [{:keys [event]}] + (is (= id (:id event))))) + + (dispatch [::re-graph/query id "{ things { id } }" {:some "variable"} [::on-thing]]) + + (re-frame/reg-fx + ::internals/send-http + (fn [_] + (is false "Should not have sent an http request for a duplicate in-flight query id"))) + + (dispatch [::re-graph/query id "{ things { id } }" {:some "variable"} [::on-thing]])))))))) + +(deftest http-query-test + (run-http-query-test nil)) + +(deftest named-http-query-test + (run-http-query-test :service-a)) + +(defn- run-http-query-error-test [instance-id] + (let [dispatch (partial dispatch-to-instance instance-id)] + (run-test-sync + (let [mock-response (atom {}) + query "{ things { id } }" + variables {:some "variable"}] + (init instance-id {:http {:url "http://foo.bar/graph-ql"} + :ws nil}) + + (re-frame/reg-fx + ::internals/send-http + (fn [fx-args] + (let [response @mock-response + {:keys [status error-code]} response] + (dispatch-response (:event fx-args) (if (= :no-error error-code) + (:body response) + (insert-http-status (:body response) status)))))) + + (re-frame/reg-event-db + ::on-thing + (fn [db [_ payload]] + (assoc db ::thing payload))) + + (testing "Query error with invalid graphql response (string body)" + (reset! mock-response {:status 403 + :body "Access Token is invalid" + :error-code :http-error}) + (let [expected-response-payload {:errors [{:message "The HTTP call failed.", + :extensions {:status 403}}]}] + (dispatch [::re-graph/query query variables [::on-thing]]) + (is (= expected-response-payload + (::thing @app-db))))) + + (testing "Query error with invalid graphql response (map body)" + (reset! mock-response {:status 403 + :body {:data nil + :errors nil} + :error-code :http-error}) + (let [expected-response-payload {:data nil + :errors [{:message "The HTTP call failed.", + :extensions {:status 403}}]}] + (dispatch [::re-graph/query query variables [::on-thing]]) + (is (= expected-response-payload + (::thing @app-db))))) + + (testing "Query error with valid graphql error response" + (reset! mock-response {:status 400 + :body {:errors [{:message "Bad field \"bad1\".", + :locations [{:line 2, :column 0}]} + {:message "Unknown argument \"limit\"." + :locations [{:line 2, :column 0}] + :extensions {:errcode 999}}]} + :error-code :http-error}) + (let [expected-response-payload {:errors [{:message "Bad field \"bad1\"." + :locations [{:line 2, :column 0}] + :extensions {:status 400}} + {:message "Unknown argument \"limit\"." + :locations [{:line 2, :column 0}] + :extensions {:errcode 999 + :status 400}}]}] + (dispatch [::re-graph/query query variables [::on-thing]]) + (is (= expected-response-payload + (::thing @app-db))))) + + (testing "Query error with valid graphql error response, insert status only if not present" + (reset! mock-response {:status 400 + :body {:errors [{:message "Bad field \"bad1\".", + :locations [{:line 2, :column 0}]} + {:message "Unknown argument \"limit\"." + :locations [{:line 2, :column 0}] + :extensions {:errcode 999 + :status 500}}]} + :error-code :http-error}) + (let [expected-response-payload {:errors [{:message "Bad field \"bad1\"." + :locations [{:line 2, :column 0}] + :extensions {:status 400}} + {:message "Unknown argument \"limit\"." + :locations [{:line 2, :column 0}] + :extensions {:errcode 999 + :status 500}}]}] + (dispatch [::re-graph/query query variables [::on-thing]]) + (is (= expected-response-payload + (::thing @app-db))))) + + (testing "No query error, body unchanged" + (let [expected-response-payload {:data {:things [{:id 1} {:id 2}]}}] + (reset! mock-response {:status 200 + :body expected-response-payload + :error-code :no-error}) + (dispatch [::re-graph/query query variables [::on-thing]]) + (is (= expected-response-payload + (::thing @app-db))))))))) + +#?(:clj + (deftest clj-http-query-error-test + (let [instance-id nil + dispatch (partial dispatch-to-instance instance-id)] + (run-test-sync + (let [query "{ things { id } }" + variables {:some "variable"} + http-url "http://foo.bar/graph-ql" + http-server-response (fn [_url & [_opts respond _raise]] + (respond {:status 400, :body {:errors [{:message "OK" + :extensions {:status 404}}]}}))] + (init instance-id {:http {:url http-url} + :ws nil}) + + (re-frame/reg-event-db + ::on-thing + (fn [db [_ payload]] + (assoc db ::thing payload))) + + (testing "http error returns correct response" + (with-redefs [hato/post http-server-response + clj-http/post http-server-response] + (let [expected-response-payload {:errors [{:message "OK", + :extensions {:status 404}}]}] + (dispatch [::re-graph/query query variables [::on-thing]]) + (is (= expected-response-payload + (::thing @app-db))))))))))) + +(deftest http-query-error-test + (run-http-query-error-test nil)) + +(deftest named-http-query-error-test + (run-http-query-error-test :service-a)) + +(defn- run-http-mutation-test [instance-id] + (let [dispatch (partial dispatch-to-instance instance-id)] + (run-test-sync + (let [expected-http-url "http://foo.bar/graph-ql"] + (init instance-id {:http {:url expected-http-url} + :ws nil}) + + (let [mutation (str "signin($login:String!,$password:String!){" + "signin(login:$login,password:$password){id}}") + params {:login "alice" :password "secret"} + expected-query-payload {:query (str "mutation " mutation) + :variables params} + expected-response-payload {:data {:id 1}}] + + (testing "Mutations can be made" + + (re-frame/reg-fx + ::internals/send-http + (fn [{:keys [event url payload]}] + (is (= expected-query-payload payload)) + (is (= expected-http-url url)) + (dispatch-response event expected-response-payload))) + + (re-frame/reg-event-db + ::on-mutate + (fn [db [_ payload]] + (assoc db ::mutation payload))) + + (dispatch [::re-graph/mutate mutation params [::on-mutate]]) + + (testing "responses are sent to the callback" + (is (= expected-response-payload + (::mutation @app-db))))) + + (testing "In flight mutations are deduplicated" + (let [id :abc-123] + (re-frame/reg-fx + ::internals/send-http + (fn [{:keys [event]}] + (is (= id (:id event))))) + + (dispatch [::re-graph/mutate id mutation params [::on-thing]]) + + (re-frame/reg-fx + ::internals/send-http + (fn [_] + (is false "Should not have sent an http request for a duplicate in-flight mutation id"))) + + (dispatch [::re-graph/mutate id mutation params [::on-thing]])))))))) + +(deftest http-mutation-test + (run-http-mutation-test nil)) + +(deftest named-http-mutation-test + (run-http-mutation-test :service-a)) + +(defn- run-http-parameters-test [instance-id] + (let [dispatch (partial dispatch-to-instance instance-id)] + (run-test-sync + (let [expected-http-url "http://foo.bar/graph-ql" + expected-request {:with-credentials? false}] + (init instance-id {:http {:url expected-http-url + :impl (constantly expected-request)} + :ws nil}) + (testing "Request can be specified" + (re-frame/reg-fx + ::internals/send-http + (fn [{:keys [request]}] + (is (= expected-request + request)))) + (dispatch [::re-graph/query "{ things { id } }" {:some "variable"} [::on-thing]]) + (dispatch [::re-graph/mutate "don't care" {:some "variable"} [::on-thing]])))))) + +(deftest http-parameters-test + (run-http-parameters-test nil)) + +(deftest named-http-parameters-test + (run-http-parameters-test :service-a)) + +(defn- run-non-re-frame-test [instance-id] + (let [db-instance #(get-in @app-db [:re-graph (or instance-id default-instance-id)]) + on-ws-message (on-ws-message (or instance-id default-instance-id)) + init (if instance-id (partial re-graph/init instance-id) re-graph/init) + subscribe (if instance-id (partial re-graph/subscribe instance-id) re-graph/subscribe) + unsubscribe (if instance-id (partial re-graph/unsubscribe instance-id) re-graph/unsubscribe) + query (if instance-id (partial re-graph/query instance-id) re-graph/query) + mutate (if instance-id (partial re-graph/mutate instance-id) re-graph/mutate)] + + (testing "can call normal functions instead of needing re-frame" + + (testing "using a websocket" + (run-test-sync + (install-websocket-stub!) + + (init {:ws {:url "ws://socket.rocket" + :connection-init-payload nil}}) + (let [expected-subscription-payload {:id "my-sub" + :type "start" + :payload {:query "subscription { things { id } }" + :variables {:some "variable"}}} + expected-unsubscription-payload {:id "my-sub" + :type "stop"} + expected-response-payload {:data {:things [{:id 1} {:id 2}]}} + callback-called? (atom false) + callback-fn (fn [payload] + (reset! callback-called? true) + (is (= expected-response-payload payload)))] + + (re-frame/reg-fx + ::internals/send-ws + (fn [[ws payload]] + (is (= ::websocket-connection ws)) + (is (= expected-subscription-payload + payload)))) + + (subscribe :my-sub "{ things { id } }" {:some "variable"} callback-fn) + + (is (get-in (db-instance) [:subscriptions "my-sub" :callback])) + + (testing "messages from the WS are sent to the callback-fn" + (on-ws-message (data->message {:type "data" + :id "my-sub" + :payload expected-response-payload})) + + (is @callback-called?)) + + (testing "and unregistered" + (re-frame/reg-fx + ::internals/send-ws + (fn [[ws payload]] + (is (= ::websocket-connection ws)) + (is (= expected-unsubscription-payload + payload)))) + + (unsubscribe :my-sub) + + (is (nil? (get-in (db-instance) [:subscriptions "my-sub"]))))))) + + (testing "using http" + (testing "queries" + (run-test-sync + (let [expected-http-url "http://foo.bar/graph-ql" + expected-query-payload {:query "query { things { id } }" + :variables {:some "variable"}} + expected-response-payload {:data {:things [{:id 1} {:id 2}]}} + callback-called? (atom false) + callback-fn (fn [payload] + (reset! callback-called? true) + (is (= expected-response-payload payload)))] + + (init {:http {:url expected-http-url} + :ws nil}) + + (re-frame/reg-fx + ::internals/send-http + (fn [{:keys [url payload event]}] + (is (= expected-query-payload + payload)) + + (is (= expected-http-url url)) + + (dispatch-response event expected-response-payload))) + + (query "{ things { id } }" {:some "variable"} callback-fn) + + (testing "responses are sent to the callback" + (is @callback-called?))))) + + (testing "mutations" + (run-test-sync + (let [expected-http-url "http://foo.bar/graph-ql" + expected-query-payload {:query "mutation { things { id } }" + :variables {:some "variable"}} + expected-response-payload {:data {:things [{:id 1} {:id 2}]}} + callback-called? (atom false) + callback-fn (fn [payload] + (reset! callback-called? true) + (is (= expected-response-payload payload)))] + + (init {:http {:url expected-http-url} + :ws nil}) + + (re-frame/reg-fx + ::internals/send-http + (fn [{:keys [url payload event]}] + (is (= expected-query-payload + payload)) + + (is (= expected-http-url url)) + (dispatch-response event expected-response-payload))) + + (mutate "{ things { id } }" {:some "variable"} callback-fn) + + (testing "responses are sent to the callback" + (is @callback-called?))))))))) + +(deftest non-re-frame-test + (run-non-re-frame-test nil)) + +(deftest named-non-re-frame-test + (run-non-re-frame-test :service-a)) + +(deftest venia-compatibility-test + (run-test-sync + (let [expected-http-url "http://foo.bar/graph-ql"] + (re-graph/init {:http {:url expected-http-url} + :ws nil}) + + (let [expected-query-payload {:query "query { things { id } }" + :variables {:some "variable"}} + expected-response-payload {:data {:things [{:id 1} {:id 2}]}}] + + (testing "Ignores 'query' at the start of the query" + + (re-frame/reg-fx + ::internals/send-http + (fn [{:keys [url payload event]}] + (is (= expected-query-payload + payload)) + + (is (= expected-http-url url)) + (dispatch-response event expected-response-payload))) + + (re-frame/reg-event-db + ::on-thing + (fn [db [_ payload]] + (assoc db ::thing payload))) + + (re-frame/dispatch [::re-graph/query "query { things { id } }" {:some "variable"} [::on-thing]]) + + (testing "responses are sent to the callback" + (is (= expected-response-payload + (::thing @app-db))))))))) + +(deftest multi-instance-test + (run-test-sync + + (re-frame/reg-fx + ::internals/connect-ws + (fn [[instance-id _options]] + ((on-open instance-id (keyword (str (name instance-id) "-connection")))))) + + (init :service-a {:ws {:url "ws://socket.rocket" + :connection-init-payload nil}}) + (init :service-b {:ws {:url "ws://socket.rocket" + :connection-init-payload nil}}) + + (let [expected-subscription-payload-a {:id "a-sub" + :type "start" + :payload {:query "subscription { things { a } }" + :variables {:some "a"}}} + expected-unsubscription-payload-a {:id "a-sub" + :type "stop"} + + expected-subscription-payload-b {:id "b-sub" + :type "start" + :payload {:query "subscription { things { b } }" + :variables {:some "b"}}} + expected-unsubscription-payload-b {:id "b-sub" + :type "stop"}] + + (testing "Subscriptions can be registered" + + (re-frame/reg-fx + ::internals/send-ws + (fn [[ws payload]] + (condp = ws + :service-a-connection + (is (= expected-subscription-payload-a payload)) + + :service-b-connection + (is (= expected-subscription-payload-b payload))))) + + (re-frame/dispatch [::re-graph/subscribe :service-a :a-sub "{ things { a } }" {:some "a"} [::on-a-thing]]) + (re-frame/dispatch [::re-graph/subscribe :service-b :b-sub "{ things { b } }" {:some "b"} [::on-b-thing]]) + + (is (= [::on-a-thing] + (get-in @app-db [:re-graph :service-a :subscriptions "a-sub" :callback]))) + + (is (= [::on-b-thing] + (get-in @app-db [:re-graph :service-b :subscriptions "b-sub" :callback]))) + + (testing "and deduplicated" + (re-frame/reg-fx + ::internals/send-ws + (fn [_] + (is false "Should not have sent a websocket message for an existing subscription"))) + + (re-frame/dispatch [::re-graph/subscribe :service-a :a-sub "{ things { a } }" {:some "a"} [::on-a-thing]]) + (re-frame/dispatch [::re-graph/subscribe :service-b :b-sub "{ things { b } }" {:some "b"} [::on-b-thing]])) + + (testing "messages from the WS are sent to the callback" + + (let [expected-response-payload-a {:data {:things [{:a 1} {:a 2}]}} + expected-response-payload-b {:data {:things [{:b 1}]}}] + (re-frame/reg-event-db + ::on-a-thing + (fn [db [_ payload]] + (assoc db ::a-thing payload))) + + (re-frame/reg-event-db + ::on-b-thing + (fn [db [_ payload]] + (assoc db ::b-thing payload))) + + ((on-ws-message :service-a) (data->message {:type "data" + :id "a-sub" + :payload expected-response-payload-a})) + + ((on-ws-message :service-b) (data->message {:type "data" + :id "b-sub" + :payload expected-response-payload-b})) + + (is (= expected-response-payload-a + (::a-thing @app-db))) + + (is (= expected-response-payload-b + (::b-thing @app-db))))) + + (testing "and unregistered" + (re-frame/reg-fx + ::internals/send-ws + (fn [[ws payload]] + (condp = ws + :service-a-connection + (is (= expected-unsubscription-payload-a payload)) + + :service-b-connection + (is (= expected-unsubscription-payload-b payload))))) + + (re-frame/dispatch [::re-graph/unsubscribe :service-a :a-sub]) + (re-frame/dispatch [::re-graph/unsubscribe :service-b :b-sub]) + + (is (nil? (get-in @app-db [:re-graph :service-a :subscriptions "a-sub"]))) + (is (nil? (get-in @app-db [:re-graph :service-b :subscriptions "b-sub"])))))))) + + +(deftest reinit-ws-test [] + (run-test-sync + (install-websocket-stub!) + + (testing "websocket connection payload is sent" + (let [last-ws-message (atom nil)] + + (re-frame/reg-fx + ::internals/send-ws + (fn [[ws payload]] + (is (= ::websocket-connection ws)) + (reset! last-ws-message payload))) + + (re-frame/dispatch [::re-graph/init {:ws {:url "ws://socket.rocket" + :connection-init-payload {:auth-token 123}}}]) + + (is (= {:type "connection_init" + :payload {:auth-token 123}} + @last-ws-message)) + + (testing "updated when re-inited" + (re-frame/dispatch [::re-graph/re-init {:ws {:connection-init-payload {:auth-token 234}}}] ) + + (is (= {:type "connection_init" + :payload {:auth-token 234}} + @last-ws-message))))))) + +(deftest re-init-http-test [] + (run-test-sync + + (testing "http headers are sent" + + (let [last-http-message (atom nil)] + (re-frame/reg-fx + ::internals/send-http + (fn [{:keys [event request]}] + (reset! last-http-message request) + (dispatch-response event {}))) + + (re-frame/dispatch [::re-graph/init {:http {:url "http://foo.bar/graph-ql" + :impl {:headers {"Authorization" 123}}} + :ws nil}]) + + (re-frame/dispatch [::re-graph/query "{ things { id } }" {:some "variable"} [::on-thing]]) + + (is (= {:headers {"Authorization" 123}} + @last-http-message)) + + (testing "and can be updated" + (re-frame/dispatch [::re-graph/re-init {:http {:impl {:headers {"Authorization" 234}}}}]) + (re-frame/dispatch [::re-graph/query "{ things { id } }" {:some "variable"} [::on-thing]]) + + (is (= {:headers {"Authorization" 234}} + @last-http-message))))))) diff --git a/test/re_graph/core_test.cljc b/test/re_graph/core_test.cljc index 8900559..b6ab25e 100644 --- a/test/re_graph/core_test.cljc +++ b/test/re_graph/core_test.cljc @@ -1,16 +1,21 @@ (ns re-graph.core-test (:require [re-graph.core :as re-graph] - [re-graph.internals :as internals :refer [default-instance-name]] + [re-graph.internals :as internals :refer [default-instance-id]] [re-frame.core :as re-frame] [re-frame.db :refer [app-db]] [day8.re-frame.test :refer [run-test-sync run-test-async wait-for] :refer-macros [run-test-sync run-test-async wait-for]] [clojure.test :refer [deftest is testing] :refer-macros [deftest is testing]] + [clojure.spec.alpha :as s] + [clojure.spec.test.alpha :as stest] #?@(:clj [[cheshire.core :as json] [hato.client :as hato] [clj-http.client :as clj-http]]))) +(stest/instrument) +(s/check-asserts true) + (def on-ws-message @#'internals/on-ws-message) (def on-open @#'internals/on-open) (def on-close @#'internals/on-close) @@ -23,30 +28,25 @@ (defn- install-websocket-stub! [] (re-frame/reg-fx ::internals/connect-ws - (fn [[instance-name _options]] - ((on-open instance-name ::websocket-connection))))) - -(defn- prepend-instance-name [instance-name [event-name & args :as event]] - (if instance-name - (into [event-name instance-name] args) - event)) - -(defn- dispatch-to-instance [instance-name event] - (re-frame/dispatch (prepend-instance-name instance-name event))) - -(defn- init [instance-name opts] - (if (nil? instance-name) - (re-frame/dispatch [::re-graph/init opts]) - (re-frame/dispatch [::re-graph/init instance-name opts]))) - -(defn- run-subscription-test [instance-name] - (let [dispatch (partial dispatch-to-instance instance-name) - db-instance #(get-in @app-db [:re-graph (or instance-name default-instance-name)]) - on-ws-message (on-ws-message (or instance-name default-instance-name))] + (fn [[instance-id _options]] + ((on-open instance-id ::websocket-connection))))) + +(defn- dispatch-to-instance [instance-id [event opts]] + (re-frame/dispatch [event (if (nil? instance-id) + opts + (assoc opts :instance-id instance-id))])) + +(defn- init [instance-id opts] + (dispatch-to-instance instance-id [::re-graph/init opts])) + +(defn- run-subscription-test [instance-id] + (let [dispatch (partial dispatch-to-instance instance-id) + db-instance #(get-in @app-db [:re-graph (or instance-id default-instance-id)]) + on-ws-message (on-ws-message (or instance-id default-instance-id))] (run-test-sync (install-websocket-stub!) - (init instance-name {:ws {:url "ws://socket.rocket" - :connection-init-payload nil}}) + (init instance-id {:ws {:url "ws://socket.rocket" + :connection-init-payload nil}}) (let [expected-subscription-payload {:id "my-sub" :type "start" @@ -64,7 +64,10 @@ (is (= expected-subscription-payload payload)))) - (dispatch [::re-graph/subscribe :my-sub "{ things { id } }" {:some "variable"} [::on-thing]]) + (dispatch [::re-graph/subscribe {:id :my-sub + :query "{ things { id } }" + :variables {:some "variable"} + :callback [::on-thing]}]) (is (= [::on-thing] (get-in (db-instance) [:subscriptions "my-sub" :callback]))) @@ -75,15 +78,19 @@ (fn [_] (is false "Should not have sent a websocket message for an existing subscription"))) - (dispatch [::re-graph/subscribe :my-sub "{ things { id } }" {:some "variable"} [::on-thing]])) + (dispatch [::re-graph/subscribe {:id :my-sub + :query "{ things { id } }" + :variables {:some "variable"} + :callback [::on-thing]}])) (testing "messages from the WS are sent to the callback" (let [expected-response-payload {:data {:things [{:id 1} {:id 2}]}}] (re-frame/reg-event-db ::on-thing - (fn [db [_ payload]] - (assoc db ::thing payload))) + [re-frame/unwrap] + (fn [db {:keys [response]}] + (assoc db ::thing response))) (on-ws-message (data->message {:type "data" :id "my-sub" @@ -97,8 +104,9 @@ (let [expected-response-payload {:errors {:message "Something went wrong"}}] (re-frame/reg-event-db ::on-thing - (fn [db [_ payload]] - (assoc db ::thing payload))) + [re-frame/unwrap] + (fn [db {:keys [response]}] + (assoc db ::thing response))) (on-ws-message (data->message {:type "error" :id "my-sub" @@ -115,7 +123,7 @@ (is (= expected-unsubscription-payload payload)))) - (dispatch [::re-graph/unsubscribe :my-sub]) + (dispatch [::re-graph/unsubscribe {:id :my-sub}]) (is (nil? (get-in (db-instance) [:subscriptions "my-sub"]))))))))) @@ -125,10 +133,10 @@ (deftest named-subscription-test (run-subscription-test :service-a)) -(defn- run-websocket-lifecycle-test [instance-name] - (let [dispatch (partial dispatch-to-instance instance-name) - db-instance #(get-in @app-db [:re-graph (or instance-name default-instance-name)]) - on-open (partial on-open (or instance-name default-instance-name))] +(defn- run-websocket-lifecycle-test [instance-id] + (let [dispatch (partial dispatch-to-instance instance-id) + db-instance #(get-in @app-db [:re-graph (or instance-id default-instance-id)]) + on-open (partial on-open (or instance-id default-instance-id))] (run-test-sync (re-frame/reg-fx @@ -141,13 +149,19 @@ :payload {:query "subscription { things { id } }" :variables {:some "variable"}}}] - (init instance-name {:ws {:url "ws://socket.rocket" - :connection-init-payload init-payload}}) + (init instance-id {:ws {:url "ws://socket.rocket" + :connection-init-payload init-payload}}) (testing "messages are queued when websocket isn't ready" - (dispatch [::re-graph/subscribe :my-sub "{ things { id } }" {:some "variable"} [::on-thing]]) - (dispatch [::re-graph/query "{ more_things { id } }" {:some "other-variable"} [::on-thing]]) + (dispatch [::re-graph/subscribe {:id :my-sub + :query "{ things { id } }" + :variables {:some "variable"} + :callback [::on-thing]}]) + + (dispatch [::re-graph/query {:query "{ more_things { id } }" + :variables {:some "other-variable"} + :callback [::on-thing]}]) (is (= 2 (count (get-in (db-instance) [:ws :queue])))) @@ -195,7 +209,7 @@ (fn [[ws]] (is (= ::websocket-connection ws))))) - (dispatch [::re-graph/destroy]) + (dispatch [::re-graph/destroy {}]) (testing "the re-graph state is set to destroyed" (is (:destroyed? (db-instance)))))))) @@ -206,10 +220,11 @@ (deftest named-websocket-lifecycle-test (run-websocket-lifecycle-test :service-a)) -(defn- run-websocket-reconnection-test [instance-name] - (let [dispatch (partial dispatch-to-instance instance-name) - db-instance #(get-in @app-db [:re-graph (or instance-name default-instance-name)]) - on-close (on-close (or instance-name default-instance-name))] +(defn- run-websocket-reconnection-test [instance-id] + (let [dispatch (partial dispatch-to-instance instance-id) + db-instance #(get-in @app-db [:re-graph (or instance-id default-instance-id)]) + on-close (on-close (or instance-id default-instance-id)) + sent-msgs (atom [])] (run-test-async (install-websocket-stub!) @@ -218,49 +233,51 @@ (fn [[{:keys [dispatch]}]] (re-frame/dispatch dispatch))) + (re-frame/reg-fx + ::internals/send-ws + (fn [[ws payload]] + (is (= ::websocket-connection ws)) + (is (or + (= "connection_init" (:type payload)) + (= {:id "my-sub" + :type "start" + :payload {:query "subscription { things { id } }" + :variables {:some "variable"}}} + payload))) + (swap! sent-msgs conj payload))) + (testing "websocket reconnects when disconnected" - (init instance-name {:ws {:url "ws://socket.rocket" - :connection-init-payload {:token "abc"} - :reconnect-timeout 0}}) - - (wait-for - [::internals/on-ws-open] - (is (get-in (db-instance) [:ws :ready?])) - - ;; create a subscription and wait for it to be sent - (let [subscription-registration [::re-graph/subscribe :my-sub "{ things { id } }" {:some "variable"} [::on-thing]] - sent-msgs (atom 0)] - (re-frame/reg-fx - ::internals/send-ws - (fn [[ws payload]] - (is (= ::websocket-connection ws)) - (is (or - (= "connection_init" (:type payload)) - (= {:id "my-sub" - :type "start" - :payload {:query "subscription { things { id } }" - :variables {:some "variable"}}} - payload))) - (swap! sent-msgs inc))) - - (dispatch subscription-registration) - - (on-close) - (wait-for - [::internals/on-ws-close] - (is (false? (get-in (db-instance) [:ws :ready?]))) - - (testing "websocket is reconnected" - (wait-for [::internals/on-ws-open] - (is (get-in (db-instance) [:ws :ready?])) - - (testing "subscriptions are resumed" - (wait-for - [(fn [event] - (= (prepend-instance-name (or instance-name default-instance-name) subscription-registration) event))] - ;; 2 connection_init - ;; 2 subscription - (is (= 4 @sent-msgs))))))))))))) + (init instance-id {:ws {:url "ws://socket.rocket" + :connection-init-payload {:token "abc"} + :reconnect-timeout 0}}) + + (let [subscription-params {:instance-id (or instance-id default-instance-id) + :id :my-sub + :query "{ things { id } }" + :variables {:some "variable"} + :callback [::on-thing]}] + + (wait-for + [::internals/on-ws-open] + (is (get-in (db-instance) [:ws :ready?])) + + ;; create a subscription and wait for it to be sent + (dispatch [::re-graph/subscribe subscription-params]) + (wait-for [::re-graph/subscribe] + (on-close) + (wait-for + [::internals/on-ws-close] + (is (false? (get-in (db-instance) [:ws :ready?]))) + + (testing "websocket is reconnected" + (wait-for [::internals/on-ws-open] + (is (get-in (db-instance) [:ws :ready?])) + + (testing "subscriptions are resumed" + (wait-for + [(fn [event] + (= [::re-graph/subscribe subscription-params] event))] + (is (= 4 (count @sent-msgs))))))))))))))) (deftest websocket-reconnection-test (run-websocket-reconnection-test nil)) @@ -268,18 +285,18 @@ (deftest named-websocket-reconnection-test (run-websocket-reconnection-test :service-a)) -(defn- run-websocket-query-test [instance-name] - (let [dispatch (partial dispatch-to-instance instance-name) - db-instance #(get-in @app-db [:re-graph (or instance-name default-instance-name)]) - on-ws-message (on-ws-message (or instance-name default-instance-name))] - (with-redefs [internals/generate-query-id (constantly "random-query-id")] +(defn- run-websocket-query-test [instance-id] + (let [dispatch (partial dispatch-to-instance instance-id) + db-instance #(get-in @app-db [:re-graph (or instance-id default-instance-id)]) + on-ws-message (on-ws-message (or instance-id default-instance-id))] + (with-redefs [internals/generate-id (constantly "random-id")] (run-test-sync (install-websocket-stub!) - (init instance-name {:ws {:url "ws://socket.rocket" - :connection-init-payload nil}}) + (init instance-id {:ws {:url "ws://socket.rocket" + :connection-init-payload nil}}) - (let [expected-query-payload {:id "random-query-id" + (let [expected-query-payload {:id "random-id" :type "start" :payload {:query "query { things { id } }" :variables {:some "variable"}}} @@ -301,20 +318,23 @@ (re-frame/reg-event-db ::on-thing - (fn [db [_ payload]] - (assoc db ::thing payload))) + [re-frame/unwrap] + (fn [db {:keys [response]}] + (assoc db ::thing response))) - (dispatch [::re-graph/query "{ things { id } }" {:some "variable"} [::on-thing]]) + (dispatch [::re-graph/query {:query "{ things { id } }" + :variables {:some "variable"} + :callback [::on-thing]}]) (testing "responses are sent to the callback" (is (= expected-response-payload (::thing @app-db)))) (on-ws-message (data->message {:type "complete" - :id "random-query-id"})) + :id "random-id"})) (testing "the callback is removed afterwards" - (is (nil? (get-in (db-instance) [:subscriptions "random-query-id"])))))))))) + (is (nil? (get-in (db-instance) [:subscriptions "random-id"])))))))))) (deftest websocket-query-test (run-websocket-query-test nil)) @@ -338,19 +358,21 @@ (fn [_] (reset! http-called? true))) - (re-frame/dispatch [::re-graph/query "{ things { id } }" {:some "variable"} [::on-thing]]) + (re-frame/dispatch [::re-graph/query {:query "{ things { id } }" + :variables {:some "variable"} + :callback [::on-thing]}]) (is @http-called?))))) -(defn- dispatch-response [[instance-name query-id] payload] - (re-frame/dispatch [::internals/http-complete instance-name query-id payload])) +(defn- dispatch-response [event payload] + (re-frame/dispatch [::internals/http-complete (assoc event :response payload)])) -(defn- run-http-query-test [instance-name] - (let [dispatch (partial dispatch-to-instance instance-name)] +(defn- run-http-query-test [instance-id] + (let [dispatch (partial dispatch-to-instance instance-id)] (run-test-sync (let [expected-http-url "http://foo.bar/graph-ql"] - (init instance-name {:http {:url expected-http-url} - :ws nil}) + (init instance-id {:http {:url expected-http-url} + :ws nil}) (let [expected-query-payload {:query "query { things { id } }" :variables {:some "variable"}} @@ -360,20 +382,23 @@ (re-frame/reg-fx ::internals/send-http - (fn [[_ _ http-url {:keys [payload]} :as fx-args]] + (fn [{:keys [url payload event]}] (is (= expected-query-payload payload)) - (is (= expected-http-url http-url)) + (is (= expected-http-url url)) - (dispatch-response fx-args expected-response-payload))) + (dispatch-response event expected-response-payload))) (re-frame/reg-event-db ::on-thing - (fn [db [_ payload]] - (assoc db ::thing payload))) + [re-frame/unwrap] + (fn [db {:keys [response]}] + (assoc db ::thing response))) - (dispatch [::re-graph/query "{ things { id } }" {:some "variable"} [::on-thing]]) + (dispatch [::re-graph/query {:query "{ things { id } }" + :variables {:some "variable"} + :callback [::on-thing]}]) (testing "responses are sent to the callback" (is (= expected-response-payload @@ -383,17 +408,23 @@ (let [id :abc-123] (re-frame/reg-fx ::internals/send-http - (fn [[_ query-id]] - (is (= id query-id)))) + (fn [{:keys [event]}] + (is (= id (:id event))))) - (dispatch [::re-graph/query id "{ things { id } }" {:some "variable"} [::on-thing]]) + (dispatch [::re-graph/query {:id id + :query "{ things { id } }" + :variables {:some "variable"} + :callback [::on-thing]}]) (re-frame/reg-fx ::internals/send-http (fn [_] (is false "Should not have sent an http request for a duplicate in-flight query id"))) - (dispatch [::re-graph/query id "{ things { id } }" {:some "variable"} [::on-thing]])))))))) + (dispatch [::re-graph/query {:id id + :query "{ things { id } }" + :variables {:some "variable"} + :callback [::on-thing]}])))))))) (deftest http-query-test (run-http-query-test nil)) @@ -401,28 +432,29 @@ (deftest named-http-query-test (run-http-query-test :service-a)) -(defn- run-http-query-error-test [instance-name] - (let [dispatch (partial dispatch-to-instance instance-name)] +(defn- run-http-query-error-test [instance-id] + (let [dispatch (partial dispatch-to-instance instance-id)] (run-test-sync (let [mock-response (atom {}) query "{ things { id } }" variables {:some "variable"}] - (init instance-name {:http {:url "http://foo.bar/graph-ql"} - :ws nil}) + (init instance-id {:http {:url "http://foo.bar/graph-ql"} + :ws nil}) (re-frame/reg-fx ::internals/send-http (fn [fx-args] (let [response @mock-response {:keys [status error-code]} response] - (dispatch-response fx-args (if (= :no-error error-code) - (:body response) - (insert-http-status (:body response) status)))))) + (dispatch-response (:event fx-args) (if (= :no-error error-code) + (:body response) + (insert-http-status (:body response) status)))))) (re-frame/reg-event-db ::on-thing - (fn [db [_ payload]] - (assoc db ::thing payload))) + [re-frame/unwrap] + (fn [db {:keys [response]}] + (assoc db ::thing response))) (testing "Query error with invalid graphql response (string body)" (reset! mock-response {:status 403 @@ -430,7 +462,9 @@ :error-code :http-error}) (let [expected-response-payload {:errors [{:message "The HTTP call failed.", :extensions {:status 403}}]}] - (dispatch [::re-graph/query query variables [::on-thing]]) + (dispatch [::re-graph/query {:query query + :variables variables + :callback [::on-thing]}]) (is (= expected-response-payload (::thing @app-db))))) @@ -442,7 +476,9 @@ (let [expected-response-payload {:data nil :errors [{:message "The HTTP call failed.", :extensions {:status 403}}]}] - (dispatch [::re-graph/query query variables [::on-thing]]) + (dispatch [::re-graph/query {:query query + :variables variables + :callback [::on-thing]}]) (is (= expected-response-payload (::thing @app-db))))) @@ -461,7 +497,9 @@ :locations [{:line 2, :column 0}] :extensions {:errcode 999 :status 400}}]}] - (dispatch [::re-graph/query query variables [::on-thing]]) + (dispatch [::re-graph/query {:query query + :variables variables + :callback [::on-thing]}]) (is (= expected-response-payload (::thing @app-db))))) @@ -481,7 +519,9 @@ :locations [{:line 2, :column 0}] :extensions {:errcode 999 :status 500}}]}] - (dispatch [::re-graph/query query variables [::on-thing]]) + (dispatch [::re-graph/query {:query query + :variables variables + :callback [::on-thing]}]) (is (= expected-response-payload (::thing @app-db))))) @@ -490,14 +530,22 @@ (reset! mock-response {:status 200 :body expected-response-payload :error-code :no-error}) - (dispatch [::re-graph/query query variables [::on-thing]]) + (dispatch [::re-graph/query {:query query + :variables variables + :callback [::on-thing]}]) (is (= expected-response-payload (::thing @app-db))))))))) +(deftest http-query-error-test + (run-http-query-error-test nil)) + +(deftest named-http-query-error-test + (run-http-query-error-test :service-a)) + #?(:clj (deftest clj-http-query-error-test - (let [instance-name nil - dispatch (partial dispatch-to-instance instance-name)] + (let [instance-id nil + dispatch (partial dispatch-to-instance instance-id)] (run-test-sync (let [query "{ things { id } }" variables {:some "variable"} @@ -505,58 +553,58 @@ http-server-response (fn [_url & [_opts respond _raise]] (respond {:status 400, :body {:errors [{:message "OK" :extensions {:status 404}}]}}))] - (init instance-name {:http {:url http-url} - :ws nil}) + (init instance-id {:http {:url http-url} + :ws nil}) (re-frame/reg-event-db ::on-thing - (fn [db [_ payload]] - (assoc db ::thing payload))) + [re-frame/unwrap] + (fn [db {:keys [response]}] + (assoc db ::thing response))) (testing "http error returns correct response" (with-redefs [hato/post http-server-response clj-http/post http-server-response] (let [expected-response-payload {:errors [{:message "OK", :extensions {:status 404}}]}] - (dispatch [::re-graph/query query variables [::on-thing]]) + (dispatch [::re-graph/query {:query query + :variables variables + :callback [::on-thing]}]) (is (= expected-response-payload (::thing @app-db))))))))))) -(deftest http-query-error-test - (run-http-query-error-test nil)) - -(deftest named-http-query-error-test - (run-http-query-error-test :service-a)) - -(defn- run-http-mutation-test [instance-name] - (let [dispatch (partial dispatch-to-instance instance-name)] +(defn- run-http-mutation-test [instance-id] + (let [dispatch (partial dispatch-to-instance instance-id)] (run-test-sync (let [expected-http-url "http://foo.bar/graph-ql"] - (init instance-name {:http {:url expected-http-url} - :ws nil}) + (init instance-id {:http {:url expected-http-url} + :ws nil}) (let [mutation (str "signin($login:String!,$password:String!){" "signin(login:$login,password:$password){id}}") - params {:login "alice" :password "secret"} + variables {:login "alice" :password "secret"} expected-query-payload {:query (str "mutation " mutation) - :variables params} + :variables variables} expected-response-payload {:data {:id 1}}] (testing "Mutations can be made" (re-frame/reg-fx ::internals/send-http - (fn [[_ _ http-url {:keys [payload]} :as fx-args]] + (fn [{:keys [event url payload]}] (is (= expected-query-payload payload)) - (is (= expected-http-url http-url)) - (dispatch-response fx-args expected-response-payload))) + (is (= expected-http-url url)) + (dispatch-response event expected-response-payload))) (re-frame/reg-event-db ::on-mutate - (fn [db [_ payload]] - (assoc db ::mutation payload))) + [re-frame/unwrap] + (fn [db {:keys [response]}] + (assoc db ::mutation response))) - (dispatch [::re-graph/mutate mutation params [::on-mutate]]) + (dispatch [::re-graph/mutate {:query mutation + :variables variables + :callback [::on-mutate]}]) (testing "responses are sent to the callback" (is (= expected-response-payload @@ -566,17 +614,23 @@ (let [id :abc-123] (re-frame/reg-fx ::internals/send-http - (fn [[_ query-id]] - (is (= id query-id)))) + (fn [{:keys [event]}] + (is (= id (:id event))))) - (dispatch [::re-graph/mutate id mutation params [::on-thing]]) + (dispatch [::re-graph/mutate {:id id + :query mutation + :variables variables + :callback [::on-mutate]}]) (re-frame/reg-fx ::internals/send-http (fn [_] (is false "Should not have sent an http request for a duplicate in-flight mutation id"))) - (dispatch [::re-graph/mutate id mutation params [::on-thing]])))))))) + (dispatch [::re-graph/mutate {:id id + :query mutation + :variables variables + :callback [::on-mutate]}])))))))) (deftest http-mutation-test (run-http-mutation-test nil)) @@ -584,23 +638,26 @@ (deftest named-http-mutation-test (run-http-mutation-test :service-a)) -(defn- run-http-parameters-test [instance-name] - (let [dispatch (partial dispatch-to-instance instance-name)] +(defn- run-http-parameters-test [instance-id] + (let [dispatch (partial dispatch-to-instance instance-id)] (run-test-sync (let [expected-http-url "http://foo.bar/graph-ql" expected-request {:with-credentials? false}] - (init instance-name {:http {:url expected-http-url - :impl (constantly expected-request)} - :ws nil}) + (init instance-id {:http {:url expected-http-url + :impl (constantly expected-request)} + :ws nil}) (testing "Request can be specified" (re-frame/reg-fx ::internals/send-http - (fn [[_ _ _http-url {:keys [request]}]] + (fn [{:keys [request]}] (is (= expected-request request)))) - (dispatch [::re-graph/query "{ things { id } }" {:some "variable"} [::on-thing]]) - (dispatch [::re-graph/mutate "don't care" {:some "variable"} [::on-thing]])))))) - + (dispatch [::re-graph/query {:query "{ things { id } }" + :variables {:some "variable"} + :callback [::on-thing]}]) + (dispatch [::re-graph/mutate {:query "don't care" + :variables {:some "variable"} + :callback [::on-thing]}])))))) (deftest http-parameters-test (run-http-parameters-test nil)) @@ -608,14 +665,20 @@ (deftest named-http-parameters-test (run-http-parameters-test :service-a)) -(defn- run-non-re-frame-test [instance-name] - (let [db-instance #(get-in @app-db [:re-graph (or instance-name default-instance-name)]) - on-ws-message (on-ws-message (or instance-name default-instance-name)) - init (if instance-name (partial re-graph/init instance-name) re-graph/init) - subscribe (if instance-name (partial re-graph/subscribe instance-name) re-graph/subscribe) - unsubscribe (if instance-name (partial re-graph/unsubscribe instance-name) re-graph/unsubscribe) - query (if instance-name (partial re-graph/query instance-name) re-graph/query) - mutate (if instance-name (partial re-graph/mutate instance-name) re-graph/mutate)] +(defn- call-instance [instance-id f] + (fn [opts] + (f (if instance-id + (assoc opts :instance-id instance-id) + opts)))) + +(defn- run-non-re-frame-test [instance-id] + (let [db-instance #(get-in @app-db [:re-graph (or instance-id default-instance-id)]) + on-ws-message (on-ws-message (or instance-id default-instance-id)) + init (call-instance instance-id re-graph/init) + subscribe (call-instance instance-id re-graph/subscribe) + unsubscribe (call-instance instance-id re-graph/unsubscribe) + query (call-instance instance-id re-graph/query) + mutate (call-instance instance-id re-graph/mutate)] (testing "can call normal functions instead of needing re-frame" @@ -644,10 +707,12 @@ (is (= expected-subscription-payload payload)))) - (subscribe :my-sub "{ things { id } }" {:some "variable"} callback-fn) + (subscribe {:id :my-sub + :query "{ things { id } }" + :variables {:some "variable"} + :callback callback-fn}) - (is (= [::internals/callback callback-fn] - (get-in (db-instance) [:subscriptions "my-sub" :callback]))) + (is (get-in (db-instance) [:subscriptions "my-sub" :callback])) (testing "messages from the WS are sent to the callback-fn" (on-ws-message (data->message {:type "data" @@ -664,7 +729,7 @@ (is (= expected-unsubscription-payload payload)))) - (unsubscribe :my-sub) + (unsubscribe {:id :my-sub}) (is (nil? (get-in (db-instance) [:subscriptions "my-sub"]))))))) @@ -685,15 +750,17 @@ (re-frame/reg-fx ::internals/send-http - (fn [[_ _ http-url {:keys [payload]} :as fx-args]] + (fn [{:keys [url payload event]}] (is (= expected-query-payload payload)) - (is (= expected-http-url http-url)) + (is (= expected-http-url url)) - (dispatch-response fx-args expected-response-payload))) + (dispatch-response event expected-response-payload))) - (query "{ things { id } }" {:some "variable"} callback-fn) + (query {:query "{ things { id } }" + :variables {:some "variable"} + :callback callback-fn}) (testing "responses are sent to the callback" (is @callback-called?))))) @@ -714,15 +781,16 @@ (re-frame/reg-fx ::internals/send-http - (fn [[_ _ http-url {:keys [payload]} :as fx-args]] + (fn [{:keys [url payload event]}] (is (= expected-query-payload payload)) - (is (= expected-http-url http-url)) - - (dispatch-response fx-args expected-response-payload))) + (is (= expected-http-url url)) + (dispatch-response event expected-response-payload))) - (mutate "{ things { id } }" {:some "variable"} callback-fn) + (mutate {:query "{ things { id } }" + :variables {:some "variable"} + :callback callback-fn}) (testing "responses are sent to the callback" (is @callback-called?))))))))) @@ -747,19 +815,22 @@ (re-frame/reg-fx ::internals/send-http - (fn [[_ _ http-url {:keys [payload]} :as fx-args]] + (fn [{:keys [url payload event]}] (is (= expected-query-payload payload)) - (is (= expected-http-url http-url)) - (dispatch-response fx-args expected-response-payload))) + (is (= expected-http-url url)) + (dispatch-response event expected-response-payload))) (re-frame/reg-event-db ::on-thing - (fn [db [_ payload]] - (assoc db ::thing payload))) + [re-frame/unwrap] + (fn [db {:keys [response]}] + (assoc db ::thing response))) - (re-frame/dispatch [::re-graph/query "query { things { id } }" {:some "variable"} [::on-thing]]) + (re-frame/dispatch [::re-graph/query {:query "query { things { id } }" + :variables {:some "variable"} + :callback [::on-thing]}]) (testing "responses are sent to the callback" (is (= expected-response-payload @@ -770,10 +841,10 @@ (re-frame/reg-fx ::internals/connect-ws - (fn [[instance-name _options]] - ((on-open instance-name (keyword (str (name instance-name) "-connection")))))) + (fn [[instance-id _options]] + ((on-open instance-id (keyword (str (name instance-id) "-connection")))))) - (init :service-a {:ws {:url "ws://socket.rocket" + (init :service-a {:ws {:url "ws://socket.rocket" :connection-init-payload nil}}) (init :service-b {:ws {:url "ws://socket.rocket" :connection-init-payload nil}}) @@ -792,80 +863,98 @@ expected-unsubscription-payload-b {:id "b-sub" :type "stop"}] - (testing "Subscriptions can be registered" + (testing "Subscriptions can be registered" + (re-frame/reg-fx + ::internals/send-ws + (fn [[ws payload]] + (condp = ws + :service-a-connection + (is (= expected-subscription-payload-a payload)) + + :service-b-connection + (is (= expected-subscription-payload-b payload))))) + + (re-frame/dispatch [::re-graph/subscribe {:instance-id :service-a + :id :a-sub + :query "{ things { a } }" + :variables {:some "a"} + :callback [::on-a-thing]}]) + (re-frame/dispatch [::re-graph/subscribe {:instance-id :service-b + :id :b-sub + :query "{ things { b } }" + :variables {:some "b"} + :callback [::on-b-thing]}]) + + (is (= [::on-a-thing] + (get-in @app-db [:re-graph :service-a :subscriptions "a-sub" :callback]))) + + (is (= [::on-b-thing] + (get-in @app-db [:re-graph :service-b :subscriptions "b-sub" :callback]))) + + (testing "and deduplicated" (re-frame/reg-fx ::internals/send-ws - (fn [[ws payload]] - (condp = ws - :service-a-connection - (is (= expected-subscription-payload-a payload)) - - :service-b-connection - (is (= expected-subscription-payload-b payload))))) - - (re-frame/dispatch [::re-graph/subscribe :service-a :a-sub "{ things { a } }" {:some "a"} [::on-a-thing]]) - (re-frame/dispatch [::re-graph/subscribe :service-b :b-sub "{ things { b } }" {:some "b"} [::on-b-thing]]) - - (is (= [::on-a-thing] - (get-in @app-db [:re-graph :service-a :subscriptions "a-sub" :callback]))) - - (is (= [::on-b-thing] - (get-in @app-db [:re-graph :service-b :subscriptions "b-sub" :callback]))) - - (testing "and deduplicated" - (re-frame/reg-fx - ::internals/send-ws - (fn [_] - (is false "Should not have sent a websocket message for an existing subscription"))) - - (re-frame/dispatch [::re-graph/subscribe :service-a :a-sub "{ things { a } }" {:some "a"} [::on-a-thing]]) - (re-frame/dispatch [::re-graph/subscribe :service-b :b-sub "{ things { b } }" {:some "b"} [::on-b-thing]])) - - (testing "messages from the WS are sent to the callback" - - (let [expected-response-payload-a {:data {:things [{:a 1} {:a 2}]}} - expected-response-payload-b {:data {:things [{:b 1}]}}] - (re-frame/reg-event-db - ::on-a-thing - (fn [db [_ payload]] - (assoc db ::a-thing payload))) + (fn [_] + (is false "Should not have sent a websocket message for an existing subscription"))) + + (re-frame/dispatch [::re-graph/subscribe {:instance-id :service-a + :id :a-sub + :query "{ things { a } }" + :variables {:some "a"} + :callback [::on-a-thing]}]) + (re-frame/dispatch [::re-graph/subscribe {:instance-id :service-b + :id :b-sub + :query "{ things { b } }" + :variables {:some "b"} + :callback [::on-b-thing]}])) + + (testing "messages from the WS are sent to the callback" + + (let [expected-response-payload-a {:data {:things [{:a 1} {:a 2}]}} + expected-response-payload-b {:data {:things [{:b 1}]}}] + (re-frame/reg-event-db + ::on-a-thing + [re-frame/unwrap] + (fn [db {:keys [response]}] + (assoc db ::a-thing response))) - (re-frame/reg-event-db - ::on-b-thing - (fn [db [_ payload]] - (assoc db ::b-thing payload))) + (re-frame/reg-event-db + ::on-b-thing + [re-frame/unwrap] + (fn [db {:keys [response]}] + (assoc db ::b-thing response))) - ((on-ws-message :service-a) (data->message {:type "data" - :id "a-sub" - :payload expected-response-payload-a})) + ((on-ws-message :service-a) (data->message {:type "data" + :id "a-sub" + :payload expected-response-payload-a})) - ((on-ws-message :service-b) (data->message {:type "data" - :id "b-sub" - :payload expected-response-payload-b})) + ((on-ws-message :service-b) (data->message {:type "data" + :id "b-sub" + :payload expected-response-payload-b})) - (is (= expected-response-payload-a - (::a-thing @app-db))) + (is (= expected-response-payload-a + (::a-thing @app-db))) - (is (= expected-response-payload-b - (::b-thing @app-db))))) + (is (= expected-response-payload-b + (::b-thing @app-db))))) - (testing "and unregistered" - (re-frame/reg-fx - ::internals/send-ws - (fn [[ws payload]] - (condp = ws - :service-a-connection - (is (= expected-unsubscription-payload-a payload)) + (testing "and unregistered" + (re-frame/reg-fx + ::internals/send-ws + (fn [[ws payload]] + (condp = ws + :service-a-connection + (is (= expected-unsubscription-payload-a payload)) - :service-b-connection - (is (= expected-unsubscription-payload-b payload))))) + :service-b-connection + (is (= expected-unsubscription-payload-b payload))))) - (re-frame/dispatch [::re-graph/unsubscribe :service-a :a-sub]) - (re-frame/dispatch [::re-graph/unsubscribe :service-b :b-sub]) + (re-frame/dispatch [::re-graph/unsubscribe {:instance-id :service-a :id :a-sub}]) + (re-frame/dispatch [::re-graph/unsubscribe {:instance-id :service-b :id :b-sub}]) - (is (nil? (get-in @app-db [:re-graph :service-a :subscriptions "a-sub"]))) - (is (nil? (get-in @app-db [:re-graph :service-b :subscriptions "b-sub"])))))))) + (is (nil? (get-in @app-db [:re-graph :service-a :subscriptions "a-sub"]))) + (is (nil? (get-in @app-db [:re-graph :service-b :subscriptions "b-sub"])))))))) (deftest reinit-ws-test [] @@ -903,22 +992,26 @@ (let [last-http-message (atom nil)] (re-frame/reg-fx ::internals/send-http - (fn [[_ _ _http-url {:keys [request]} :as fx-args]] + (fn [{:keys [event request]}] (reset! last-http-message request) - (dispatch-response fx-args {}))) + (dispatch-response event {}))) (re-frame/dispatch [::re-graph/init {:http {:url "http://foo.bar/graph-ql" :impl {:headers {"Authorization" 123}}} :ws nil}]) - (re-frame/dispatch [::re-graph/query "{ things { id } }" {:some "variable"} [::on-thing]]) + (re-frame/dispatch [::re-graph/query {:query "{ things { id } }" + :variables {:some "variable"} + :callback [::on-thing]}]) (is (= {:headers {"Authorization" 123}} @last-http-message)) (testing "and can be updated" (re-frame/dispatch [::re-graph/re-init {:http {:impl {:headers {"Authorization" 234}}}}]) - (re-frame/dispatch [::re-graph/query "{ things { id } }" {:some "variable"} [::on-thing]]) + (re-frame/dispatch [::re-graph/query {:query "{ things { id } }" + :variables {:some "variable"} + :callback [::on-thing]}]) (is (= {:headers {"Authorization" 234}} @last-http-message))))))) diff --git a/test/re_graph/deprecated_integration_test.cljc b/test/re_graph/deprecated_integration_test.cljc new file mode 100644 index 0000000..e89213a --- /dev/null +++ b/test/re_graph/deprecated_integration_test.cljc @@ -0,0 +1,169 @@ +(ns re-graph.deprecated-integration-test + (:require [re-graph.core-deprecated :as re-graph] + [re-graph.core :as re-graph-core] + [re-graph.internals :as internals] + #?(:clj [clojure.test :refer [deftest testing is use-fixtures]] + :cljs [cljs.test :refer-macros [deftest testing is]]) + [day8.re-frame.test :refer [run-test-async wait-for #?(:clj with-temp-re-frame-state)]] + [re-frame.core :as re-frame] + [re-frame.db :as rfdb] + #?(:clj [re-graph.integration-server :refer [with-server]]))) + +#?(:clj (use-fixtures :once with-server)) + +(defn register-callback! [] + (re-frame/reg-event-db + ::callback + (fn [db [_ response]] + (assoc db ::response response)))) + +(deftest async-http-query-test + (run-test-async + (re-graph/init {:ws nil + :http {:url "http://localhost:8888/graphql"}}) + (register-callback!) + + (wait-for + [::re-graph-core/init] + (testing "async query" + (re-graph/query "{ pets { id name } }" {} #(re-frame/dispatch [::callback %])) + + (wait-for + [::callback] + + (is (= {:data + {:pets + [{:id "123", :name "Billy"} + {:id "234", :name "Bob"} + {:id "345", :name "Beatrice"}]}} + (::response @rfdb/app-db))) + + (testing "instances, query ids, etc" + ;; todo + ) + + (testing "http parameters" + ;; todo + )))))) + +(deftest async-http-mutate-test + (run-test-async + (re-graph/init {:ws nil + :http {:url "http://localhost:8888/graphql"}}) + (register-callback!) + + (wait-for + [::re-graph-core/init] + + (testing "async mutate" + (re-graph/mutate "mutation { createPet(name: \"Zorro\") { id name } }" {} #(re-frame/dispatch [::callback %])) + + (wait-for + [::callback] + (is (= {:data {:createPet {:id "999", :name "Zorro"}}} + (::response @rfdb/app-db)))))))) + +#?(:clj + (deftest sync-http-test + (with-temp-re-frame-state + (re-graph/init {:ws nil + :http {:url "http://localhost:8888/graphql"}}) + + (testing "sync query" + (is (= {:data + {:pets + [{:id "123", :name "Billy"} + {:id "234", :name "Bob"} + {:id "345", :name "Beatrice"}]}} + (re-graph/query-sync "{ pets { id name } }" {})))) + + (testing "sync mutate" + (is (= {:data {:createPet {:id "999", :name "Zorro"}}} + (re-graph/mutate-sync "mutation { createPet(name: \"Zorro\") { id name } }" {})))) + + (testing "error handling" + (is (= {:errors + [{:message "Cannot query field `malformed' on type `Query'.", + :locations [{:line 1, :column 9}], + :extensions {:type-name "Query" + :field-name "malformed" + :status 400}}]} + (re-graph/query-sync "{ malformed }" {}))))))) + +(deftest websocket-query-test + (run-test-async + (re-graph/init {:ws {:url "ws://localhost:8888/graphql-ws"} + :http nil}) + + (re-frame/reg-fx + ::internals/disconnect-ws + (fn [_] + (re-frame/dispatch [::ws-disconnected]))) + + (re-frame/reg-event-fx + ::ws-disconnected + (fn [& _args] + ;; do nothing + {})) + + (re-frame/reg-event-db + ::complete + (fn [db _] + db)) + + (re-frame/reg-event-fx + ::callback + (fn [{:keys [db]} [_ response]] + (let [new-db (update db ::responses conj response)] + (merge + {:db new-db} + (when (<= 5 (count (::responses new-db))) + {:dispatch [::complete]}))))) + + (wait-for + [::re-graph-core/init] + + (testing "subscriptions" + (re-graph/subscribe :all-pets "MyPets($count: Int) { pets(count: $count) { id name } }" {:count 5} + #(re-frame/dispatch [::callback %])) + + (wait-for + [::complete] + (let [responses (::responses @rfdb/app-db)] + (is (every? #(= {:data + {:pets + [{:id "123", :name "Billy"} + {:id "234", :name "Bob"} + {:id "345", :name "Beatrice"}]}} + %) + responses)) + (is (= 5 (count responses))))))))) + +(deftest websocket-mutation-test + (run-test-async + (re-graph/init {:ws {:url "ws://localhost:8888/graphql-ws"} + :http nil}) + (register-callback!) + + (re-frame/reg-fx + ::internals/disconnect-ws + (fn [_] + (re-frame/dispatch [::ws-disconnected]))) + + (re-frame/reg-event-fx + ::ws-disconnected + (fn [& _args] + ;; do nothing + {})) + + (wait-for + [::re-graph-core/init] + + (testing "mutations" + (testing "async mutate" + (re-graph/mutate "mutation { createPet(name: \"Zorro\") { id name } }" {} #(re-frame/dispatch [::callback %])) + + (wait-for + [::callback] + (is (= {:data {:createPet {:id "999", :name "Zorro"}}} + (::response @rfdb/app-db))))))))) diff --git a/test/re_graph/integration_test.cljc b/test/re_graph/integration_test.cljc index f8f9080..f7ec3db 100644 --- a/test/re_graph/integration_test.cljc +++ b/test/re_graph/integration_test.cljc @@ -6,8 +6,13 @@ [day8.re-frame.test :refer [run-test-async wait-for #?(:clj with-temp-re-frame-state)]] [re-frame.core :as re-frame] [re-frame.db :as rfdb] + [clojure.spec.alpha :as s] + [clojure.spec.test.alpha :as stest] #?(:clj [re-graph.integration-server :refer [with-server]]))) +(stest/instrument) +(s/check-asserts true) + #?(:clj (use-fixtures :once with-server)) (defn register-callback! [] @@ -23,7 +28,9 @@ (register-callback!) (testing "async query" - (re-graph/query "{ pets { id name } }" {} #(re-frame/dispatch [::callback %])) + (re-graph/query {:query "{ pets { id name } }" + :variables {} + :callback #(re-frame/dispatch [::callback %])}) (wait-for [::callback] @@ -50,7 +57,9 @@ (register-callback!) (testing "async mutate" - (re-graph/mutate "mutation { createPet(name: \"Zorro\") { id name } }" {} #(re-frame/dispatch [::callback %])) + (re-graph/mutate {:query "mutation { createPet(name: \"Zorro\") { id name } }" + :variables {} + :callback #(re-frame/dispatch [::callback %])}) (wait-for [::callback] @@ -69,11 +78,13 @@ [{:id "123", :name "Billy"} {:id "234", :name "Bob"} {:id "345", :name "Beatrice"}]}} - (re-graph/query-sync "{ pets { id name } }" {})))) + (re-graph/query-sync {:query "{ pets { id name } }" + :variables {}})))) (testing "sync mutate" (is (= {:data {:createPet {:id "999", :name "Zorro"}}} - (re-graph/mutate-sync "mutation { createPet(name: \"Zorro\") { id name } }" {})))) + (re-graph/mutate-sync {:query "mutation { createPet(name: \"Zorro\") { id name } }" + :variables {}})))) (testing "error handling" (is (= {:errors @@ -82,76 +93,79 @@ :extensions {:type-name "Query" :field-name "malformed" :status 400}}]} - (re-graph/query-sync "{ malformed }" {}))))))) + (re-graph/query-sync {:query "{ malformed }" + :variables {}}))))))) (deftest websocket-query-test (run-test-async (re-graph/init {:ws {:url "ws://localhost:8888/graphql-ws"} :http nil}) - (re-frame/reg-fx - ::internals/disconnect-ws - (fn [_] - (re-frame/dispatch [::ws-disconnected]))) - - (re-frame/reg-event-fx - ::ws-disconnected - (fn [& _args] - ;; do nothing - {})) - - (re-frame/reg-event-db - ::complete - (fn [db _] - db)) - - (re-frame/reg-event-fx - ::callback - (fn [{:keys [db]} [_ response]] - (let [new-db (update db ::responses conj response)] - (merge - {:db new-db} - (when (<= 5 (count (::responses new-db))) - {:dispatch [::complete]}))))) - - (testing "subscriptions" - (re-graph/subscribe :all-pets "MyPets($count: Int) { pets(count: $count) { id name } }" {:count 5} - #(re-frame/dispatch [::callback %])) - - (wait-for - [::complete] - (let [responses (::responses @rfdb/app-db)] - (is (every? #(= {:data - {:pets - [{:id "123", :name "Billy"} - {:id "234", :name "Bob"} - {:id "345", :name "Beatrice"}]}} - %) - responses)) - (is (= 5 (count responses)))))))) + (wait-for [::re-graph/init] + + (re-frame/reg-event-db + ::complete + (fn [db _] + db)) + + (re-frame/reg-event-fx + ::callback + [re-frame/unwrap] + (fn [{:keys [db]} {:keys [response]}] + (let [new-db (update db ::responses conj response)] + (merge + {:db new-db} + (when (<= 5 (count (::responses new-db))) + {:dispatch [::complete]}))))) + + (testing "subscriptions" + (re-graph/subscribe {:id :all-pets + :query "MyPets($count: Int) { pets(count: $count) { id name } }" + :variables {:count 5} + :callback #(re-frame/dispatch [::callback {:response %}])}) + + (wait-for + [::complete] + (let [responses (::responses @rfdb/app-db)] + (is (every? #(= {:data + {:pets + [{:id "123", :name "Billy"} + {:id "234", :name "Bob"} + {:id "345", :name "Beatrice"}]}} + %) + responses)) + (is (= 5 (count responses))) + + #_(re-graph/destroy) + #_(wait-for [::re-graph/destroy] + (println "test complete")))))))) (deftest websocket-mutation-test (run-test-async (re-graph/init {:ws {:url "ws://localhost:8888/graphql-ws"} :http nil}) - (register-callback!) - (re-frame/reg-fx - ::internals/disconnect-ws - (fn [_] - (re-frame/dispatch [::ws-disconnected]))) - - (re-frame/reg-event-fx - ::ws-disconnected - (fn [& _args] - ;; do nothing - {})) - - (testing "mutations" - (testing "async mutate" - (re-graph/mutate "mutation { createPet(name: \"Zorro\") { id name } }" {} #(re-frame/dispatch [::callback %])) - - (wait-for - [::callback] - (is (= {:data {:createPet {:id "999", :name "Zorro"}}} - (::response @rfdb/app-db)))))))) + (wait-for [::re-graph/init] + (register-callback!) + + (re-frame/reg-fx + ::internals/disconnect-ws + (fn [_] + (re-frame/dispatch [::ws-disconnected]))) + + (re-frame/reg-event-fx + ::ws-disconnected + (fn [& _args] + ;; do nothing + {})) + + (testing "mutations" + (testing "async mutate" + (re-graph/mutate {:query "mutation { createPet(name: \"Zorro\") { id name } }" + :variables {} + :callback #(re-frame/dispatch [::callback %])}) + + (wait-for + [::callback] + (is (= {:data {:createPet {:id "999", :name "Zorro"}}} + (::response @rfdb/app-db))))))))) diff --git a/test/re_graph/internals_test.cljc b/test/re_graph/internals_test.cljc index 9c52598..f10dbf8 100644 --- a/test/re_graph/internals_test.cljc +++ b/test/re_graph/internals_test.cljc @@ -6,8 +6,7 @@ [clojure.test :refer [deftest is testing] :refer-macros [deftest is testing]])) -(defn- run-options-test - [] +(deftest options-test (run-test-sync (testing "WebSocket options" @@ -30,51 +29,3 @@ (let [test-url "http://example.org/graphql" options (internals/http-options {:http {:url test-url}})] (is (= test-url (get-in options [:http :url]))))))) - -(deftest options-test - (run-options-test)) - -(deftest instance-interceptor-test - (with-redefs [internals/generate-query-id (constantly "")] - (let [instance (fn [re-graph event] - (-> ((:before internals/re-graph-instance) {:coeffects {:db {:re-graph re-graph} - :event event - :original-event (into [::re-graph/query] event)}}) - :coeffects - (dissoc :db :original-event)))] - - (testing "does nothing when re-graph not initialised" - (is (= {:event ["{query}" {} [::callback]]} - (instance nil - ["{query}" {} [::callback]])))) - - (testing "does nothing when re-graph is destroyed" - (is (= {:event ["{query}" {} [::callback]]} - (instance {internals/default-instance-name {:destroyed? true}} - ["{query}" {} [::callback]])))) - - (testing "selects the default instance when no instance name provided" - (is (= {:event ["" "{query}" {} [::callback]] - :instance-name internals/default-instance-name - :dispatchable-event [::re-graph/query internals/default-instance-name "" "{query}" {} [::callback]]} - (instance {internals/default-instance-name ::instance} - ["{query}" {} [::callback]])))) - - (testing "named instances" - (testing "selects the named instance when instance name provided" - (is (= {:event ["" "{query}" {} [::callback]] - :instance-name ::my-instance - :dispatchable-event [::re-graph/query ::my-instance "" "{query}" {} [::callback]]} - (instance {internals/default-instance-name ::instance - ::my-instance ::my-instance} - [::my-instance "{query}" {} [::callback]])))) - - (testing "does nothing when instance name provided but does not exist" - ;; todo improve this using better destructuring - ;; this should fail because ::my-instance does not exist - ;; but instead the default instance is selected - #_(is (= {:event ["" "{query}" {} [::callback]] - :instance-name ::my-instance - :dispatchable-event [::re-graph/query ::my-instance "" "{query}" {} [::callback]]} - (instance {internals/default-instance-name ::instance} - [::my-instance "{query}" {} [::callback]]))))))))