├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ └── elixir.yml ├── .gitignore ├── LICENSE ├── README.md ├── config ├── config.exs ├── dev.exs └── test.exs ├── lib └── subscriptions_transport_ws │ ├── error.ex │ ├── operation_message.ex │ ├── socket.ex │ └── test │ └── socket_test.ex ├── mix.exs ├── mix.lock └── test ├── integration └── subscription_transport_ws_test.exs ├── subscriptions_transport_ws ├── operation_message_test.exs └── socket_test.exs ├── support ├── test_schema.exs └── websocket_client.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | locals_without_parens = [assert_receive_subscription: 1, assert_receive_subscription: 2] 3 | 4 | [ 5 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 6 | locals_without_parens: locals_without_parens, 7 | export: [ 8 | locals_without_parens: locals_without_parens 9 | ] 10 | ] 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | 10 | - package-ecosystem: "github-actions" 11 | directory: "/" 12 | schedule: 13 | interval: "daily" 14 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 7 | strategy: 8 | matrix: 9 | include: 10 | - elixir: 1.11.x 11 | otp: 23.x 12 | check_formatted: true 13 | check_style: true 14 | steps: 15 | - uses: actions/checkout@v3.1.0 16 | - uses: erlef/setup-elixir@v1.14 17 | with: 18 | otp-version: ${{matrix.otp}} 19 | elixir-version: ${{matrix.elixir}} 20 | - name: Install Dependencies 21 | run: mix deps.get && mix deps.unlock --check-unused 22 | - name: Check formatting 23 | if: matrix.check_formatted 24 | run: mix format --check-formatted 25 | - name: Check style 26 | if: matrix.check_style 27 | run: mix credo --format flycheck 28 | - name: Compile project 29 | run: mix compile --warnings-as-errors 30 | - name: Run tests 31 | run: mix test --cover 32 | - name: Retrieve PLT Cache 33 | uses: actions/cache@v3 34 | id: plt-cache 35 | with: 36 | path: priv/plts 37 | key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('mix.lock') }} 38 | - name: Create PLTs 39 | if: steps.plt-cache.outputs.cache-hit != 'true' 40 | run: | 41 | mkdir -p priv/plts 42 | mix dialyzer --plt 43 | 44 | - name: Run dialyzer 45 | run: mix dialyzer --no-check --halt-exit-status 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | subscriptions_transport_ws-*.tar 24 | 25 | 26 | # Temporary files for e.g. tests 27 | /tmp 28 | 29 | # Dialyxir 30 | /priv/plts/*.plt 31 | /priv/plts/*.plt.hash -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Maarten van Vliet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SubscriptionsTransportWS 2 | 3 | ## [![Hex pm](http://img.shields.io/hexpm/v/subscriptions_transport_ws.svg?style=flat)](https://hex.pm/packages/subscriptions_transport_ws) [![Hex Docs](https://img.shields.io/badge/hex-docs-9768d1.svg)](https://hexdocs.pm/subscriptions_transport_ws) [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)![.github/workflows/elixir.yml](https://github.com/maartenvanvliet/subscriptions-transport-ws/workflows/.github/workflows/elixir.yml/badge.svg) 4 | 5 | 6 | Implementation of the subscriptions-transport-ws graphql subscription protocol for Absinthe. Instead of using Absinthe subscriptions over Phoenix channels it exposes a websocket directly. This allows you to use 7 | the Apollo and Urql Graphql clients without using a translation layer to Phoenix channels such as `@absinthe/socket`. 8 | 9 | Has been tested with Apollo iOS/ Apollo JS and Urql with subscriptions-transport-ws. 10 | 11 | ## Subscriptions-Transport-WS vs Graphql-WS 12 | `subscriptions-transport-ws` is an older [protocol](https://github.com/apollographql/subscriptions-transport-ws). A newer one has been written named [graphql_ws](https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md). The grapqhl_ws protocol is more robust, and the way to go in the future. 13 | 14 | At the time of writing the major libraries support one, the other or both. E.g. Apollo Swift currently [only supports](https://github.com/apollographql/apollo-ios/issues/1622#issuecomment-892189145) `subscriptions-transport-ws`, v3 of Apollo Android supports `graphql_ws`. The Urlq/Apollo JS libraries support either one. 15 | 16 | If you need to support `graphql_ws` on the backend in Elixir, you can use the [absinthe_graphql_ws](https://github.com/geometerio/absinthe_graphql_ws) library. You can set up multiple websocket endpoints to support both protocols. 17 | 18 | ## Installation 19 | 20 | The package can be installed by adding `subscriptions_transport_ws` to your list of dependencies in `mix.exs`: 21 | 22 | ```elixir 23 | def deps do 24 | [ 25 | {:subscriptions_transport_ws, "~> 1.0.0"} 26 | ] 27 | end 28 | ``` 29 | 30 | ## Usage 31 | 32 | There are several steps to use this library. 33 | 34 | You need to have a working phoenix pubsub configured. Here is what the default looks like if you create a new phoenix project: 35 | ```elixir 36 | config :my_app, MyAppWeb.Endpoint, 37 | # ... other config 38 | pubsub_server: MyApp.PubSub 39 | ``` 40 | In your application supervisor add a line AFTER your existing endpoint supervision line: 41 | 42 | ```elixir 43 | [ 44 | # other children ... 45 | MyAppWeb.Endpoint, # this line should already exist 46 | {Absinthe.Subscription, MyAppWeb.Endpoint}, # add this line 47 | # other children ... 48 | ] 49 | ``` 50 | 51 | Where MyAppWeb.Endpoint is the name of your application's phoenix endpoint. 52 | 53 | Add a module in your app `lib/web/channels/absinthe_socket.ex` 54 | ```elixir 55 | defmodule AbsintheSocket do 56 | # App.GraphqlSchema is your graphql schema 57 | use SubscriptionsTransportWS.Socket, schema: App.GraphqlSchema, keep_alive: 1000 58 | 59 | # Callback similar to default Phoenix UserSocket 60 | @impl true 61 | def connect(params, socket) do 62 | {:ok, socket} 63 | end 64 | 65 | # Callback to authenticate the user 66 | @impl true 67 | def gql_connection_init(message, socket) do 68 | {:ok, socket} 69 | end 70 | end 71 | ``` 72 | 73 | In your MyAppWeb.Endpoint module add: 74 | ```elixir 75 | defmodule MyAppWeb.Endpoint do 76 | use Phoenix.Endpoint, otp_app: :my_app 77 | use Absinthe.Phoenix.Endpoint 78 | 79 | socket("/absinthe-ws", AbsintheSocket, websocket: [subprotocols: ["graphql-ws"]]) 80 | # ... 81 | end 82 | ``` 83 | 84 | Now if you start your app you can connect to the socket on `ws://localhost:4000/absinthe-ws/websocket` 85 | 86 | ## Example with Apollo JS 87 | ```javascript 88 | import { 89 | ApolloClient, 90 | InMemoryCache, 91 | ApolloProvider, 92 | useSubscription, 93 | } from "@apollo/client"; 94 | import { split, HttpLink } from "@apollo/client"; 95 | import { getMainDefinition } from "@apollo/client/utilities"; 96 | import { WebSocketLink } from "@apollo/client/link/ws"; 97 | 98 | const wsLink = new WebSocketLink({ 99 | uri: "ws://localhost:4000/absinthe-ws/websocket", 100 | options: { 101 | reconnect: true, 102 | }, 103 | }); 104 | const httpLink = new HttpLink({ 105 | uri: "http://localhost:4000/api", 106 | }); 107 | 108 | const splitLink = split( 109 | ({ query }) => { 110 | const definition = getMainDefinition(query); 111 | return ( 112 | definition.kind === "OperationDefinition" && 113 | definition.operation === "subscription" 114 | ); 115 | }, 116 | wsLink, 117 | httpLink 118 | ); 119 | 120 | const client = new ApolloClient({ 121 | uri: "http://localhost:4000/api", 122 | cache: new InMemoryCache(), 123 | link: splitLink, 124 | }); 125 | ``` 126 | 127 | See the [Apollo documentation](https://www.apollographql.com/docs/react/data/subscriptions/) for more information 128 | 129 | 130 | ## Example with Urql 131 | ```javascript 132 | import { SubscriptionClient } from "subscriptions-transport-ws"; 133 | import { 134 | useSubscription, 135 | Provider, 136 | defaultExchanges, 137 | subscriptionExchange, 138 | } from "urql"; 139 | 140 | const subscriptionClient = new SubscriptionClient( 141 | "ws://localhost:4000/absinthe-ws/websocket", 142 | { 143 | reconnect: true, 144 | } 145 | ); 146 | 147 | const client = new Client({ 148 | url: "http://localhost:4000/api", 149 | exchanges: [ 150 | subscriptionExchange({ 151 | forwardSubscription(operation) { 152 | return subscriptionClient.request(operation); 153 | }, 154 | }), 155 | ...defaultExchanges, 156 | ], 157 | }); 158 | ``` 159 | See the [Urql documentation](https://formidable.com/open-source/urql/docs/advanced/subscriptions/#setting-up-subscriptions-transport-ws) for more information. 160 | 161 | ## Example with Swift Apollo 162 | 163 | ```swift 164 | import Apollo 165 | import ApolloSQLite 166 | import ApolloWebSocket 167 | import Foundation 168 | import Combine 169 | 170 | class ApolloService { 171 | static let shared = ApolloService() 172 | static let url = Config.host.appendingPathComponent("api") 173 | 174 | private(set) lazy var client: ApolloClient = { 175 | 176 | let store = ApolloStore() 177 | 178 | let requestChainTransport = RequestChainNetworkTransport( 179 | interceptorProvider: DefaultInterceptorProvider(store: store), 180 | endpointURL: "https://localhost:4000/api" 181 | ) 182 | 183 | // The Normal Apollo Web Socket Implementation which uses an Apollo adapter server side 184 | let wsUrl = "wss://localhost:4000/absinthe-ws/websocket" 185 | let wsRequest = URLRequest(url: wsUrl) 186 | let wsClient = WebSocket(request: wsRequest) 187 | let apolloWebSocketTransport = WebSocketTransport(websocket: wsClient) 188 | 189 | let splitNetworkTransport = SplitNetworkTransport( 190 | uploadingNetworkTransport: requestChainTransport, 191 | webSocketNetworkTransport: apolloWebSocketTransport 192 | ) 193 | 194 | // Remember to give the store you already created to the client so it 195 | // doesn't create one on its own 196 | let client = ApolloClient( 197 | networkTransport: splitNetworkTransport, 198 | store: store 199 | ) 200 | 201 | return client 202 | }() 203 | } 204 | ``` 205 | 206 | Or see here https://www.apollographql.com/docs/ios/subscriptions/#subscriptions-and-authorization-tokens 207 | 208 | 209 | 210 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 211 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 212 | be found at [https://hexdocs.pm/subscriptions_transport_ws](https://hexdocs.pm/subscription_transport_ws). 213 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | import_config "#{Mix.env()}.exs" 4 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :phoenix, :json_library, Jason 4 | -------------------------------------------------------------------------------- /lib/subscriptions_transport_ws/error.ex: -------------------------------------------------------------------------------- 1 | defmodule SubscriptionsTransportWS.Error do 2 | defexception [:message] 3 | end 4 | -------------------------------------------------------------------------------- /lib/subscriptions_transport_ws/operation_message.ex: -------------------------------------------------------------------------------- 1 | defmodule SubscriptionsTransportWS.OperationMessage do 2 | @moduledoc """ 3 | Struct to contain the protocol messages. 4 | 5 | See https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md 6 | for more details on their contents 7 | 8 | """ 9 | 10 | @enforce_keys [:type] 11 | defstruct [:type, :id, :payload] 12 | 13 | @type t :: %__MODULE__{type: String.t(), id: String.t(), payload: any()} 14 | alias SubscriptionsTransportWS.Error 15 | 16 | @message_types ~w( 17 | complete 18 | connection_ack 19 | connection_error 20 | connection_init 21 | connection_terminate 22 | data 23 | error 24 | ka 25 | start 26 | stop 27 | ) 28 | @doc """ 29 | Prepare message for transport, removing any keys with nil values. 30 | 31 | iex> %OperationMessage{type: "complete", id: "1"} |> OperationMessage.as_json 32 | %{id: "1", type: "complete"} 33 | """ 34 | def as_json(%__MODULE__{type: type} = message) when type in @message_types do 35 | message 36 | |> Map.from_struct() 37 | |> Enum.reject(&match?({_, nil}, &1)) 38 | |> Map.new() 39 | end 40 | 41 | def as_json(%__MODULE__{type: type}) do 42 | raise Error, "Illegal `type` #{inspect(type)} in OperationMessage" 43 | end 44 | 45 | def as_json(_) do 46 | raise Error, "Missing `type` in OperationMessage" 47 | end 48 | 49 | @doc """ 50 | Build `OperationMessage` from incoming map 51 | 52 | iex> %{"type" => "connection_init"} |> OperationMessage.from_map 53 | %OperationMessage{id: nil, type: "connection_init", payload: nil} 54 | """ 55 | def from_map(%{"type" => type} = message) when type in @message_types do 56 | %__MODULE__{ 57 | id: Map.get(message, "id"), 58 | payload: Map.get(message, "payload"), 59 | type: Map.get(message, "type") 60 | } 61 | end 62 | 63 | def from_map(%{"type" => type}) do 64 | raise Error, "Illegal `type` #{inspect(type)} in OperationMessage" 65 | end 66 | 67 | def from_map(_) do 68 | raise Error, "Missing `type` in OperationMessage" 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/subscriptions_transport_ws/socket.ex: -------------------------------------------------------------------------------- 1 | defmodule SubscriptionsTransportWS.Socket do 2 | @external_resource "README.md" 3 | @moduledoc @external_resource 4 | |> File.read!() 5 | |> String.split("") 6 | |> Enum.fetch!(1) 7 | 8 | require Logger 9 | 10 | alias SubscriptionsTransportWS.OperationMessage 11 | alias __MODULE__ 12 | 13 | @typedoc """ 14 | 15 | When using this module there are several options available 16 | 17 | * `json_module` - defaults to Jason 18 | * `schema` - refers to the Absinthe schema (required) 19 | * `pipeline` - refers to the Absinthe pipeline to use, defaults to `{SubscriptionsTransportWS.Socket, :default_pipeline}` 20 | * `keep_alive` period in ms to send keep alive messages over the socket, defaults to 10000 21 | * `ping_interval` period in ms to send keep pings to the client, the client should respond with pong to keep the connection alive 22 | 23 | ## Example 24 | 25 | ```elixir 26 | use SubscriptionsTransportWS.Socket, schema: App.GraphqlSchema, keep_alive: 1000 27 | ``` 28 | 29 | """ 30 | defstruct [ 31 | :handler, 32 | :pubsub_server, 33 | :endpoint, 34 | :json_module, 35 | keep_alive: 10_000, 36 | serializer: Phoenix.Socket.V2.JSONSerializer, 37 | operations: %{}, 38 | assigns: %{}, 39 | ping_interval: 30_000 40 | ] 41 | 42 | @type t :: %Socket{ 43 | assigns: map, 44 | endpoint: atom, 45 | handler: atom, 46 | pubsub_server: atom, 47 | serializer: atom, 48 | json_module: atom, 49 | operations: map, 50 | keep_alive: integer | nil, 51 | ping_interval: integer | nil 52 | } 53 | 54 | @type control() :: 55 | :ping 56 | | :pong 57 | 58 | @type opcode() :: 59 | :text 60 | | :binary 61 | | control() 62 | 63 | @type message() :: binary() 64 | @type frame() :: {opcode(), message()} 65 | 66 | @initial_keep_alive_wait 1 67 | 68 | @doc """ 69 | Receives the socket params and authenticates the connection. 70 | 71 | ## Socket params and assigns 72 | 73 | Socket params are passed from the client and can 74 | be used to verify and authenticate a user. After 75 | verification, you can put default assigns into 76 | the socket that will be set for all channels, ie 77 | 78 | {:ok, assign(socket, :user_id, verified_user_id)} 79 | 80 | To deny connection, return `:error`. 81 | 82 | See `Phoenix.Token` documentation for examples in 83 | performing token verification on connect. 84 | """ 85 | @callback connect(params :: map, Socket.t()) :: {:ok, Socket.t()} | :error 86 | @callback connect(params :: map, Socket.t(), connect_info :: map) :: {:ok, Socket.t()} | :error 87 | 88 | @doc """ 89 | Callback for the `connection_init` message. 90 | The client sends this message after plain websocket connection to start 91 | the communication with the server. 92 | 93 | In the `subscriptions-transport-ws` protocol this is usually used to 94 | set the user on the socket. 95 | 96 | Should return `{:ok, socket}` on success, and `{:error, payload}` to deny. 97 | 98 | Receives the a map of `connection_params`, see 99 | 100 | * connectionParams in [Apollo javascript client](https://github.com/apollographql/subscriptions-transport-ws/blob/06b8eb81ba2b6946af4faf0ae6369767b31a2cc9/src/client.ts#L62) 101 | * connectingPayload in [Apollo iOS client](https://github.com/apollographql/apollo-ios/blob/ca023e5854b5b78529eafe9006c6ce1e3c2db539/docs/source/api/ApolloWebSocket/classes/WebSocketTransport.md) 102 | 103 | or similar in other clients. 104 | """ 105 | @callback gql_connection_init(connection_params :: map, Socket.t()) :: 106 | {:ok, Socket.t()} | {:error, any} 107 | 108 | @callback handle_message(params :: term(), Socket.t()) :: 109 | {:ok, Socket.t()} 110 | | {:push, frame(), Socket.t()} 111 | | {:stop, term(), Socket.t()} 112 | @optional_callbacks connect: 2, connect: 3, handle_message: 2 113 | 114 | defmacro __using__(opts) do 115 | quote do 116 | import SubscriptionsTransportWS.Socket 117 | 118 | alias SubscriptionsTransportWS.Socket 119 | 120 | @phoenix_socket_options unquote(opts) 121 | 122 | @behaviour Phoenix.Socket.Transport 123 | @behaviour SubscriptionsTransportWS.Socket 124 | 125 | @doc false 126 | @impl true 127 | def child_spec(opts) do 128 | Socket.__child_spec__( 129 | __MODULE__, 130 | opts, 131 | @phoenix_socket_options 132 | ) 133 | end 134 | 135 | @doc false 136 | @impl true 137 | def connect(state), 138 | do: 139 | Socket.__connect__( 140 | __MODULE__, 141 | state, 142 | @phoenix_socket_options 143 | ) 144 | 145 | @doc false 146 | @impl true 147 | def init(socket), do: Socket.__init__(socket) 148 | 149 | @doc false 150 | @impl true 151 | def handle_in(message, socket), 152 | do: Socket.__in__(message, socket) 153 | 154 | @doc false 155 | @impl true 156 | def handle_info(message, socket), 157 | do: Socket.__info__(message, socket) 158 | 159 | @impl true 160 | def handle_control(message, socket), 161 | do: Socket.__control__(message, socket) 162 | 163 | @doc false 164 | @impl true 165 | def terminate(reason, socket), 166 | do: Socket.__terminate__(reason, socket) 167 | end 168 | end 169 | 170 | def __child_spec__(module, _opts, _socket_options) do 171 | # Nothing to do here, so noop. 172 | %{id: {__MODULE__, module}, start: {Task, :start_link, [fn -> :ok end]}, restart: :transient} 173 | end 174 | 175 | def __connect__(module, socket, socket_options) do 176 | json_module = Keyword.get(socket_options, :json_module, Jason) 177 | schema = Keyword.get(socket_options, :schema) 178 | pipeline = Keyword.get(socket_options, :pipeline) 179 | keep_alive = Keyword.get(socket_options, :keep_alive) 180 | ping_interval = Keyword.get(socket_options, :ping_interval) 181 | 182 | case user_connect( 183 | module, 184 | socket.endpoint, 185 | socket.params, 186 | socket.connect_info, 187 | json_module, 188 | keep_alive, 189 | ping_interval 190 | ) do 191 | {:ok, socket} -> 192 | absinthe_config = Map.get(socket.assigns, :absinthe, %{}) 193 | 194 | opts = 195 | absinthe_config 196 | |> Map.get(:opts, []) 197 | |> Keyword.update(:context, %{pubsub: socket.endpoint}, fn context -> 198 | Map.put_new(context, :pubsub, socket.endpoint) 199 | end) 200 | 201 | absinthe_config = 202 | put_in(absinthe_config[:opts], opts) 203 | |> Map.update(:schema, schema, & &1) 204 | 205 | absinthe_config = 206 | Map.put(absinthe_config, :pipeline, pipeline || {__MODULE__, :default_pipeline}) 207 | 208 | socket = socket |> assign(:absinthe, absinthe_config) 209 | 210 | {:ok, socket} 211 | 212 | :error -> 213 | :error 214 | end 215 | end 216 | 217 | defp user_connect( 218 | handler, 219 | endpoint, 220 | params, 221 | connect_info, 222 | json_module, 223 | keep_alive, 224 | ping_interval 225 | ) do 226 | if pubsub_server = endpoint.config(:pubsub_server) do 227 | socket = %SubscriptionsTransportWS.Socket{ 228 | handler: handler, 229 | endpoint: endpoint, 230 | pubsub_server: pubsub_server, 231 | json_module: json_module, 232 | keep_alive: keep_alive, 233 | ping_interval: ping_interval 234 | } 235 | 236 | connect_result = 237 | if function_exported?(handler, :connect, 3) do 238 | handler.connect(params, socket, connect_info) 239 | else 240 | handler.connect(params, socket) 241 | end 242 | 243 | connect_result 244 | else 245 | Logger.error(""" 246 | The :pubsub_server was not configured for endpoint #{inspect(endpoint)}. 247 | 248 | Make sure to start a PubSub proccess in your application supervision tree: 249 | {Phoenix.PubSub, [name: YOURAPP.PubSub, adapter: Phoenix.PubSub.PG2]} 250 | 251 | And then list it your endpoint config: 252 | pubsub_server: YOURAPP.PubSub 253 | """) 254 | 255 | :error 256 | end 257 | end 258 | 259 | @doc """ 260 | Adds key value pairs to socket assigns. 261 | A single key value pair may be passed, a keyword list or map 262 | of assigns may be provided to be merged into existing socket 263 | assigns. 264 | 265 | ## Examples 266 | 267 | iex> assign(socket, :name, "Elixir") 268 | iex> assign(socket, name: "Elixir", logo: "💧") 269 | 270 | """ 271 | def assign(socket, key, value) do 272 | assign(socket, [{key, value}]) 273 | end 274 | 275 | def assign(socket, attrs) when is_map(attrs) or is_list(attrs) do 276 | %{socket | assigns: Map.merge(socket.assigns, Map.new(attrs))} 277 | end 278 | 279 | @doc """ 280 | Sets the options for a given GraphQL document execution. 281 | 282 | ## Examples 283 | 284 | iex> SubscriptionsTransportWS.Socket.put_options(socket, context: %{current_user: user}) 285 | %SubscriptionsTransportWS.Socket{} 286 | """ 287 | def put_options(socket, opts) do 288 | absinthe_assigns = 289 | socket.assigns 290 | |> Map.get(:absinthe, %{}) 291 | 292 | absinthe_assigns = 293 | absinthe_assigns 294 | |> Map.put(:opts, Keyword.merge(Map.get(absinthe_assigns, :opts, []), opts)) 295 | 296 | assign(socket, :absinthe, absinthe_assigns) 297 | end 298 | 299 | @doc """ 300 | Adds key-value pairs into Absinthe context. 301 | 302 | ## Examples 303 | 304 | iex> Socket.assign_context(socket, current_user: user) 305 | %Socket{} 306 | """ 307 | def assign_context(%Socket{assigns: %{absinthe: absinthe}} = socket, context) do 308 | context = 309 | absinthe 310 | |> Map.get(:opts, []) 311 | |> Keyword.get(:context, %{}) 312 | |> Map.merge(Map.new(context)) 313 | 314 | put_options(socket, context: context) 315 | end 316 | 317 | def assign_context(socket, assigns) do 318 | put_options(socket, context: Map.new(assigns)) 319 | end 320 | 321 | @doc """ 322 | Same as `assign_context/2` except one key-value pair is assigned. 323 | """ 324 | def assign_context(socket, key, value) do 325 | assign_context(socket, [{key, value}]) 326 | end 327 | 328 | @doc false 329 | def __init__(state) do 330 | {:ok, state} 331 | end 332 | 333 | @doc false 334 | def __in__({text, _opts}, socket) do 335 | message = socket.json_module.decode!(text) 336 | 337 | message = OperationMessage.from_map(message) 338 | 339 | handle_message(socket, message) 340 | end 341 | 342 | def __control__({_, opcode: :ping}, socket), do: {:reply, :ok, {:pong, "pong"}, socket} 343 | def __control__({_, opcode: :pong}, socket), do: {:ok, socket} 344 | 345 | @doc false 346 | def __info__(:keep_alive, socket) do 347 | reply = 348 | %OperationMessage{type: "ka"} 349 | |> OperationMessage.as_json() 350 | |> socket.json_module.encode! 351 | 352 | Process.send_after(self(), :keep_alive, socket.keep_alive) 353 | 354 | {:push, {:text, reply}, socket} 355 | end 356 | 357 | def __info__(:ping, socket) do 358 | Process.send_after(self(), :ping, socket.ping_interval) 359 | {:push, {:ping, "ping"}, socket} 360 | end 361 | 362 | def __info__({:socket_push, :text, message}, socket) do 363 | message = socket.serializer.decode!(message, opcode: :text) 364 | 365 | id = Map.get(socket.operations, message.topic) 366 | 367 | reply = 368 | %OperationMessage{type: "data", id: id, payload: %{data: message.payload["result"]["data"]}} 369 | |> OperationMessage.as_json() 370 | |> socket.json_module.encode! 371 | 372 | {:push, {:text, reply}, socket} 373 | end 374 | 375 | def __info__(message, socket) do 376 | if function_exported?(socket.handler, :handle_message, 2) do 377 | socket.handler.handle_message(message, socket) 378 | else 379 | {:ok, socket} 380 | end 381 | end 382 | 383 | @doc false 384 | def __terminate__(_reason, _state) do 385 | :ok 386 | end 387 | 388 | @doc """ 389 | Default pipeline to use for Absinthe graphql document execution 390 | """ 391 | def default_pipeline(schema, options) do 392 | schema 393 | |> Absinthe.Pipeline.for_document(options) 394 | end 395 | 396 | defp handle_message(socket, %{type: "connection_init"} = message) do 397 | case socket.handler.gql_connection_init(message, socket) do 398 | {:ok, socket} -> 399 | if socket.keep_alive do 400 | Process.send_after(self(), :keep_alive, @initial_keep_alive_wait) 401 | end 402 | 403 | if socket.ping_interval do 404 | Process.send_after(self(), :ping, socket.ping_interval) 405 | end 406 | 407 | reply = 408 | %OperationMessage{type: "connection_ack"} 409 | |> OperationMessage.as_json() 410 | |> socket.json_module.encode! 411 | 412 | {:reply, :ok, {:text, reply}, socket} 413 | 414 | {:error, payload} -> 415 | reply = 416 | %OperationMessage{type: "connection_error", payload: payload} 417 | |> OperationMessage.as_json() 418 | |> socket.json_module.encode! 419 | 420 | {:reply, :ok, {:text, reply}, socket} 421 | end 422 | end 423 | 424 | defp handle_message(socket, %{type: "stop", id: id}) do 425 | doc_id = 426 | Enum.find_value(socket.operations, fn {key, op_id} -> 427 | if id == op_id, do: key 428 | end) 429 | 430 | reply = 431 | %OperationMessage{type: "complete", id: id} 432 | |> OperationMessage.as_json() 433 | |> socket.json_module.encode! 434 | 435 | case doc_id do 436 | nil -> 437 | {:reply, :ok, {:text, reply}, socket} 438 | 439 | doc_id -> 440 | pubsub = 441 | socket.assigns 442 | |> Map.get(:absinthe, %{}) 443 | |> Map.get(:opts, []) 444 | |> Keyword.get(:context, %{}) 445 | |> Map.get(:pubsub, socket.endpoint) 446 | 447 | Phoenix.PubSub.unsubscribe(socket.pubsub_server, doc_id) 448 | Absinthe.Subscription.unsubscribe(pubsub, doc_id) 449 | socket = %{socket | operations: Map.delete(socket.operations, doc_id)} 450 | 451 | {:reply, :ok, {:text, reply}, socket} 452 | end 453 | end 454 | 455 | defp handle_message(socket, %{type: "start", payload: payload, id: id}) do 456 | config = socket.assigns[:absinthe] 457 | 458 | case extract_variables(payload) do 459 | variables when is_map(variables) -> 460 | opts = Keyword.put(config.opts, :variables, variables) 461 | query = Map.get(payload, "query", "") 462 | 463 | Absinthe.Logger.log_run(:debug, {query, config.schema, [], opts}) 464 | 465 | {reply, socket} = run_doc(socket, query, config, opts, id) 466 | 467 | Logger.debug(fn -> 468 | """ 469 | -- Absinthe Phoenix Reply -- 470 | #{inspect(reply)} 471 | ---------------------------- 472 | """ 473 | end) 474 | 475 | if reply != :noreply do 476 | case reply do 477 | {:ok, operation_message} -> 478 | {:reply, :ok, 479 | {:text, OperationMessage.as_json(operation_message) |> socket.json_module.encode!}, 480 | socket} 481 | end 482 | else 483 | {:ok, socket} 484 | end 485 | 486 | _ -> 487 | reply = %OperationMessage{ 488 | type: "error", 489 | id: id, 490 | payload: %{errors: "Could not parse variables"} 491 | } 492 | 493 | {:reply, :ok, {:text, OperationMessage.as_json(reply) |> socket.json_module.encode!}, 494 | socket} 495 | end 496 | end 497 | 498 | defp handle_message(socket, %{type: "connection_terminate"}) do 499 | Enum.each(socket.operations, fn {doc_id, _} -> 500 | pubsub = 501 | socket.assigns 502 | |> Map.get(:absinthe, %{}) 503 | |> Map.get(:opts, []) 504 | |> Keyword.get(:context, %{}) 505 | |> Map.get(:pubsub, socket.endpoint) 506 | 507 | Phoenix.PubSub.unsubscribe(socket.pubsub_server, doc_id) 508 | Absinthe.Subscription.unsubscribe(pubsub, doc_id) 509 | end) 510 | 511 | socket = %{socket | operations: %{}} 512 | {:ok, socket} 513 | end 514 | 515 | defp extract_variables(payload) do 516 | case Map.get(payload, "variables", %{}) do 517 | nil -> %{} 518 | map -> map 519 | end 520 | end 521 | 522 | defp run_doc(socket, query, config, opts, id) do 523 | case run(query, config[:schema], config[:pipeline], opts) do 524 | {:ok, %{"subscribed" => topic}, context} -> 525 | :ok = 526 | Phoenix.PubSub.subscribe( 527 | socket.pubsub_server, 528 | topic, 529 | metadata: {:fastlane, self(), socket.serializer, []} 530 | ) 531 | 532 | socket = put_options(socket, context: context) 533 | 534 | socket = %{socket | operations: Map.put(socket.operations, topic, id)} 535 | 536 | {:noreply, socket} 537 | 538 | {:ok, %{data: data}, context} -> 539 | socket = put_options(socket, context: context) 540 | 541 | reply = %OperationMessage{ 542 | type: "data", 543 | id: id, 544 | payload: %{data: data} 545 | } 546 | 547 | {{:ok, reply}, socket} 548 | 549 | {:ok, %{errors: errors}, context} -> 550 | socket = put_options(socket, context: context) 551 | 552 | reply = %OperationMessage{ 553 | type: "data", 554 | id: id, 555 | payload: %{data: %{}, errors: errors} 556 | } 557 | 558 | {{:ok, reply}, socket} 559 | 560 | {:error, error} -> 561 | reply = %OperationMessage{ 562 | type: "error", 563 | id: id, 564 | payload: %{errors: error} 565 | } 566 | 567 | {{:ok, reply}, socket} 568 | end 569 | end 570 | 571 | defp run(document, schema, pipeline, options) do 572 | {module, fun} = pipeline 573 | 574 | case Absinthe.Pipeline.run(document, apply(module, fun, [schema, options])) do 575 | {:ok, %{result: result, execution: res}, _phases} -> 576 | {:ok, result, res.context} 577 | 578 | {:error, msg, _phases} -> 579 | {:error, msg} 580 | end 581 | end 582 | end 583 | -------------------------------------------------------------------------------- /lib/subscriptions_transport_ws/test/socket_test.ex: -------------------------------------------------------------------------------- 1 | defmodule SubscriptionsTransportWS.SocketTest do 2 | @moduledoc """ 3 | Helper module for testing socket behaviours. 4 | 5 | ## Usage 6 | 7 | ```elixir 8 | # Example socket 9 | defmodule GraphqlSocket do 10 | use SubscriptionsTransportWS.Socket, schema: TestSchema, keep_alive: 10 11 | 12 | @impl true 13 | def connect(params, socket) do 14 | {:ok, socket} 15 | end 16 | 17 | @impl true 18 | def gql_connection_init(message, socket) do 19 | {:ok, socket} 20 | end 21 | end 22 | 23 | # Endpoint routes to the socket 24 | defmodule YourApp.Endpoint do 25 | use Phoenix.Endpoint, otp_app: :subscription_transport_ws 26 | use Absinthe.Phoenix.Endpoint 27 | 28 | socket("/ws", GraphqlSocket, websocket: [subprotocols: ["graphql-ws"]]) 29 | # ... rest of your endpoint 30 | end 31 | 32 | # Test suite 33 | defmodule SomeTest do 34 | use ExUnit.Case 35 | import SubscriptionsTransportWS.SocketTest 36 | 37 | @endpoint YourApp.Endpoint 38 | 39 | test "a test" do 40 | socket(GraphqlSocket, TestSchema) 41 | 42 | # Push query over socket and receive response 43 | assert {:ok, %{"data" => %{"posts" => [%{"body" => "body1", "id" => "aa"}]}}, _socket} = push_doc(socket, "query { 44 | posts { 45 | id 46 | body 47 | } 48 | }", variables: %{limit: 10}) 49 | 50 | 51 | # Subscribe to subscription 52 | {:ok, socket} = push_doc(socket, "subscription { 53 | postAdded{ 54 | id 55 | body 56 | title 57 | } 58 | }", variables: %{}) 59 | 60 | end 61 | end 62 | ``` 63 | """ 64 | alias Phoenix.Socket.V2.JSONSerializer 65 | alias SubscriptionsTransportWS.OperationMessage 66 | alias SubscriptionsTransportWS.Socket 67 | 68 | @doc """ 69 | Helper function to build a socket. 70 | 71 | ## Example 72 | ```elixir 73 | iex> socket = socket(GraphqlSocket, TestSchema) 74 | ``` 75 | """ 76 | defmacro socket(socket_module, schema) do 77 | build_socket(socket_module, [], schema, __CALLER__) 78 | end 79 | 80 | defp build_socket(socket_module, assigns, schema, caller) do 81 | if endpoint = Module.get_attribute(caller.module, :endpoint) do 82 | quote do 83 | %Socket{ 84 | assigns: 85 | Enum.into(unquote(assigns), %{ 86 | absinthe: %{ 87 | opts: [context: %{pubsub: unquote(endpoint)}], 88 | schema: unquote(schema), 89 | pipeline: {SubscriptionsTransportWS.Socket, :default_pipeline} 90 | } 91 | }), 92 | endpoint: unquote(endpoint), 93 | handler: unquote(socket_module), 94 | json_module: Jason, 95 | pubsub_server: unquote(caller.module) 96 | } 97 | end 98 | else 99 | raise "module attribute @endpoint not set for socket/2" 100 | end 101 | end 102 | 103 | @doc """ 104 | Initiates a transport connection for the socket handler. 105 | Useful for testing UserSocket authentication. Returns 106 | the result of the handler's `connect/3` callback. 107 | """ 108 | defmacro connect(handler, params, connect_info \\ quote(do: %{})) do 109 | if endpoint = Module.get_attribute(__CALLER__.module, :endpoint) do 110 | quote do 111 | unquote(__MODULE__).__connect__( 112 | unquote(endpoint), 113 | unquote(handler), 114 | unquote(params), 115 | unquote(connect_info) 116 | ) 117 | end 118 | else 119 | raise "module attribute @endpoint not set for socket/2" 120 | end 121 | end 122 | 123 | @doc """ 124 | Helper function to receive subscription data over the socket 125 | 126 | ## Example 127 | ```elixir 128 | push_doc(socket, "mutation submitPost($title: String, $body: String){ 129 | submitPost(title: $title, body: $body){ 130 | id 131 | body 132 | title 133 | 134 | } 135 | }", variables: %{title: "test title", body: "test body"}) 136 | 137 | assert_receive_subscription %{ 138 | "data" => %{ 139 | "postAdded" => %{"body" => "test body", "id" => "1", "title" => "test title"} 140 | } 141 | } 142 | ``` 143 | """ 144 | defmacro assert_receive_subscription( 145 | payload, 146 | timeout \\ Application.fetch_env!(:ex_unit, :assert_receive_timeout) 147 | ) do 148 | quote do 149 | assert_receive {:socket_push, :text, message}, unquote(timeout) 150 | message = JSONSerializer.decode!(message, opcode: :text) 151 | assert unquote(payload) = message.payload["result"] 152 | end 153 | end 154 | 155 | @doc false 156 | def __connect__(endpoint, handler, params, connect_info) do 157 | map = %{ 158 | endpoint: endpoint, 159 | options: [], 160 | params: __stringify__(params), 161 | connect_info: connect_info 162 | } 163 | 164 | case handler.connect(map) do 165 | {:ok, state} -> handler.init(state) 166 | error -> error 167 | end 168 | end 169 | 170 | @doc """ 171 | Helper function for the `connection_init` message in the subscriptions-transport-ws 172 | protocol. Calls the `gql_connection_init(message, socket)` on the socket handler. 173 | 174 | """ 175 | def gql_connection_init(socket, params) do 176 | push(socket, %OperationMessage{type: "connection_init", payload: params}) 177 | end 178 | 179 | @doc """ 180 | Helper function to push a GraphQL document to a socket. 181 | 182 | The only option that is used is `opts[:variables]` - all other options are 183 | ignored. 184 | 185 | When you push a query/mutation it will return with `{:ok, result, socket}`. For 186 | subscriptions it will return an `{:ok, socket}` tuple. 187 | 188 | ## Example of synchronous response 189 | ```elixir 190 | # Push query over socket and receive response 191 | push_doc(socket, "query { 192 | posts { 193 | id 194 | body 195 | } 196 | }", variables: %{limit: 10}) 197 | {:ok, %{"data" => %{"posts" => [%{"body" => "body1", "id" => "aa"}]}}, _socket} 198 | ``` 199 | 200 | ## Example of asynchronous response 201 | ``` 202 | # Subscribe to subscription 203 | push_doc(socket, "subscription { 204 | postAdded{ 205 | id 206 | body 207 | title 208 | } 209 | }", variables: %{}) 210 | 211 | # The submitPost mutation triggers the postAdded subscription publication 212 | push_doc(socket, "mutation submitPost($title: String, $body: String){ 213 | submitPost(title: $title, body: $body){ 214 | id 215 | body 216 | title 217 | 218 | } 219 | }", variables: %{title: "test title", body: "test body"}) 220 | 221 | assert_receive_subscription(%{ 222 | "data" => %{ 223 | "postAdded" => %{"body" => "test body", "id" => "1", "title" => "test title"} 224 | } 225 | }) 226 | ``` 227 | """ 228 | @spec push_doc(socket :: Socket.t(), document :: String.t(), opts :: [{:variables, map}]) :: 229 | {:ok, Socket.t()} | {:ok, result :: map, Socket.t()} 230 | def push_doc(socket, document, opts \\ []) do 231 | case push(socket, %OperationMessage{ 232 | id: opts[:id] || 1, 233 | type: "start", 234 | payload: %{query: document, variables: opts[:variables] || %{}} 235 | }) do 236 | {:ok, socket} -> 237 | {:ok, socket} 238 | 239 | {:reply, :ok, {:text, message}, socket} -> 240 | {:ok, 241 | socket.json_module.decode!(message) |> OperationMessage.from_map() |> Map.get(:payload), 242 | socket} 243 | end 244 | end 245 | 246 | # Lowlevel helper to push messages to the socket. Expects message map that can be converted to 247 | # an OperationMessage 248 | @doc false 249 | @spec push(socket :: Socket.t(), message :: map) :: 250 | {:ok, Socket.t()} | {:reply, :ok, {:text, String.t()}, Socket.t()} 251 | defp push(socket, message) do 252 | message = OperationMessage.as_json(message) |> socket.json_module.encode! 253 | socket.handler.handle_in({message, []}, socket) 254 | end 255 | 256 | @doc false 257 | def __stringify__(%{__struct__: _} = struct), 258 | do: struct 259 | 260 | def __stringify__(%{} = params), 261 | do: Enum.into(params, %{}, &stringify_kv/1) 262 | 263 | def __stringify__(other), 264 | do: other 265 | 266 | defp stringify_kv({k, v}), 267 | do: {to_string(k), __stringify__(v)} 268 | end 269 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule SubscriptionsTransportWS.MixProject do 2 | use Mix.Project 3 | 4 | @url "https://github.com/maartenvanvliet/subscriptions-transport-ws" 5 | def project do 6 | [ 7 | app: :subscriptions_transport_ws, 8 | version: "1.0.3", 9 | elixir: "~> 1.11", 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps(), 12 | source_url: @url, 13 | homepage_url: @url, 14 | name: "SubscriptionsTransportWS", 15 | description: 16 | "Implementation of the subscriptions-transport-ws graphql subscription protocol for Absinthe.", 17 | package: [ 18 | maintainers: ["Maarten van Vliet"], 19 | licenses: ["MIT"], 20 | links: %{"GitHub" => @url}, 21 | files: ~w(LICENSE README.md lib mix.exs .formatter.exs) 22 | ], 23 | docs: [ 24 | main: "SubscriptionsTransportWS.Socket", 25 | canonical: "http://hexdocs.pm/subscriptions-transport-ws", 26 | source_url: @url, 27 | nest_modules_by_prefix: [SubscriptionsTransportWS] 28 | ], 29 | dialyzer: dialyzer() 30 | ] 31 | end 32 | 33 | # Run "mix help compile.app" to learn about applications. 34 | def application do 35 | [ 36 | extra_applications: [:logger] 37 | ] 38 | end 39 | 40 | defp dialyzer do 41 | [ 42 | plt_add_deps: :apps_direct, 43 | plt_add_apps: [:absinthe, :phoenix_pubsub], 44 | plt_core_path: "priv/plts", 45 | plt_file: {:no_warn, "priv/plts/dialyzer.plt"} 46 | ] 47 | end 48 | 49 | # Run "mix help deps" to learn about dependencies. 50 | defp deps do 51 | [ 52 | {:absinthe_phoenix, "~> 2.0"}, 53 | {:jason, "~> 1.1", optional: true}, 54 | {:websocket_client, git: "https://github.com/jeremyong/websocket_client.git", only: :test}, 55 | {:plug_cowboy, "~> 2.2", only: :test}, 56 | {:ex_doc, "~> 0.23", only: [:dev, :test]}, 57 | {:credo, "~> 1.5", only: [:dev, :test], runtime: false}, 58 | {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, 59 | {:makeup_js, "~> 0.1", only: [:dev, :test]} 60 | ] 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "absinthe": {:hex, :absinthe, "1.6.3", "f8c7a581fa2382bde1adadd405f5caf6597623b48d160367f759d0d6e6292f84", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4a11192165fb3b7566ffdd86eccd12f0a87158b0b18c00e0400899e8df0225ea"}, 3 | "absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.1", "112cb3468db2748a85bd8bd3f4d6d33f37408a96cb170077026ace96ddb1bab2", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.5", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "e773adc876fbc84fb05a82e125d9c263f129520b36e5576554ffcb8cf49db445"}, 4 | "absinthe_plug": {:hex, :absinthe_plug, "1.5.5", "be913e77df1947ffb654a1cf1a90e28d84dc23241f6404053750bae513ccd52b", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "6c366615d9422444774206aff3448bb9cfb4e849e0c9a94a275085097bc67509"}, 5 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 6 | "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"}, 7 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"}, 8 | "cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"}, 9 | "credo": {:hex, :credo, "1.6.2", "2f82b29a47c0bb7b72f023bf3a34d151624f1cbe1e6c4e52303b05a11166a701", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "ae9dc112bc368e7b145c547bec2ed257ef88955851c15057c7835251a17211c6"}, 10 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 11 | "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, 12 | "earmark_parser": {:hex, :earmark_parser, "1.4.28", "0bf6546eb7cd6185ae086cbc5d20cd6dbb4b428aad14c02c49f7b554484b4586", [:mix], [], "hexpm", "501cef12286a3231dc80c81352a9453decf9586977f917a96e619293132743fb"}, 13 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 14 | "ex_doc": {:hex, :ex_doc, "0.29.0", "4a1cb903ce746aceef9c1f9ae8a6c12b742a5461e6959b9d3b24d813ffbea146", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "f096adb8bbca677d35d278223361c7792d496b3fc0d0224c9d4bc2f651af5db1"}, 15 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 16 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, 17 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 18 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 19 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 20 | "makeup_js": {:hex, :makeup_js, "0.1.0", "ffa8ce9db95d14dcd09045334539d5992d540d63598c592d4805b7674bdd6675", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "3f0c1a5eb52c9737b1679c926574e83bb260ccdedf08b58ee96cca7c685dea75"}, 21 | "mime": {:hex, :mime, "1.5.0", "203ef35ef3389aae6d361918bf3f952fa17a09e8e43b5aa592b93eba05d0fb8d", [:mix], [], "hexpm", "55a94c0f552249fc1a3dd9cd2d3ab9de9d3c89b559c2bd01121f824834f24746"}, 22 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 23 | "phoenix": {:hex, :phoenix, "1.5.8", "71cfa7a9bb9a37af4df98939790642f210e35f696b935ca6d9d9c55a884621a4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "35ded0a32f4836168c7ab6c33b88822eccd201bcd9492125a9bea4c54332d955"}, 24 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, 25 | "plug": {:hex, :plug, "1.11.0", "f17217525597628298998bc3baed9f8ea1fa3f1160aa9871aee6df47a6e4d38e", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2d9c633f0499f9dc5c2fd069161af4e2e7756890b81adcbb2ceaa074e8308876"}, 26 | "plug_cowboy": {:hex, :plug_cowboy, "2.4.1", "779ba386c0915027f22e14a48919a9545714f849505fa15af2631a0d298abf0f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d72113b6dff7b37a7d9b2a5b68892808e3a9a752f2bf7e503240945385b70507"}, 27 | "plug_crypto": {:hex, :plug_crypto, "1.2.1", "5c854427528bf61d159855cedddffc0625e2228b5f30eff76d5a4de42d896ef4", [:mix], [], "hexpm", "6961c0e17febd9d0bfa89632d391d2545d2e0eb73768f5f50305a23961d8782c"}, 28 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, 29 | "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, 30 | "websocket_client": {:git, "https://github.com/jeremyong/websocket_client.git", "9a6f65d05ebf2725d62fb19262b21f1805a59fbf", []}, 31 | } 32 | -------------------------------------------------------------------------------- /test/integration/subscription_transport_ws_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("../support/websocket_client.exs", __DIR__) 2 | Code.require_file("../support/test_schema.exs", __DIR__) 3 | 4 | defmodule SubscriptionsTransportWS.Integration.SocketTest do 5 | use ExUnit.Case 6 | import ExUnit.CaptureLog 7 | 8 | alias __MODULE__.Endpoint 9 | alias SubscriptionsTransportWS.OperationMessage 10 | alias SubscriptionsTransportWS.WebsocketClient 11 | 12 | @moduletag capture_log: true 13 | 14 | defmodule GraphqlSocket do 15 | use SubscriptionsTransportWS.Socket, schema: TestSchema, keep_alive: 10 16 | 17 | @impl true 18 | def connect(_params, socket) do 19 | {:ok, socket} 20 | end 21 | 22 | @impl true 23 | def gql_connection_init(message, socket) do 24 | case message.payload do 25 | %{"token" => "bogus"} -> 26 | {:error, "no user found"} 27 | 28 | %{"token" => "correct"} -> 29 | {:ok, socket |> Socket.assign_context(current_user: :user)} 30 | 31 | _ -> 32 | {:ok, socket} 33 | end 34 | end 35 | end 36 | 37 | defmodule Endpoint do 38 | use Phoenix.Endpoint, otp_app: :subscription_transport_ws 39 | use Absinthe.Phoenix.Endpoint 40 | 41 | socket("/ws", GraphqlSocket, websocket: [subprotocols: ["graphql-ws"]]) 42 | end 43 | 44 | @port 5807 45 | Application.put_env(:subscription_transport_ws, Endpoint, 46 | https: false, 47 | http: [port: @port], 48 | debug_errors: false, 49 | server: true, 50 | pubsub_server: __MODULE__, 51 | secret_key_base: String.duplicate("a", 64) 52 | ) 53 | 54 | setup_all do 55 | capture_log(fn -> start_supervised!(Endpoint) end) 56 | start_supervised!({Phoenix.PubSub, name: __MODULE__}) 57 | 58 | start_supervised!( 59 | {Absinthe.Subscription, SubscriptionsTransportWS.Integration.SocketTest.Endpoint} 60 | ) 61 | 62 | :ok 63 | end 64 | 65 | @path "ws://127.0.0.1:#{@port}/ws/websocket" 66 | @json_module Jason 67 | 68 | test "handles `connection_init` with valid credentials" do 69 | {:ok, socket} = WebsocketClient.start_link(self(), @path, @json_module) 70 | 71 | WebsocketClient.send_event(socket, ~s({ 72 | "type": "connection_init", 73 | "payload": { 74 | "token": "correct" 75 | } 76 | })) 77 | 78 | assert_receive %{"type" => "connection_ack"} 79 | end 80 | 81 | test "handles `connection_init` with invalid credentials" do 82 | {:ok, socket} = WebsocketClient.start_link(self(), @path, @json_module) 83 | 84 | WebsocketClient.send_event(socket, ~s({ 85 | "type": "connection_init", 86 | "payload": { 87 | "token": "bogus" 88 | } 89 | })) 90 | 91 | assert_receive %{"type" => "connection_error", "payload" => "no user found"} 92 | end 93 | 94 | test "handles query" do 95 | {:ok, socket} = WebsocketClient.start_link(self(), @path, @json_module) 96 | 97 | WebsocketClient.send_message(socket, %OperationMessage{ 98 | type: "start", 99 | id: 1, 100 | payload: %{ 101 | query: " query { 102 | posts { 103 | id 104 | body 105 | } 106 | }", 107 | variables: %{} 108 | } 109 | }) 110 | 111 | assert_receive %{ 112 | "payload" => %{"data" => %{"posts" => [%{"body" => "body1", "id" => "aa"}]}}, 113 | "type" => "data", 114 | "id" => 1 115 | } 116 | end 117 | 118 | test "receives published data on subscription" do 119 | {:ok, socket} = WebsocketClient.start_link(self(), @path, @json_module) 120 | 121 | WebsocketClient.send_message(socket, %OperationMessage{ 122 | type: "start", 123 | id: 1, 124 | payload: %{ 125 | query: "subscription { 126 | postAdded{ 127 | id 128 | body 129 | title 130 | 131 | } 132 | }", 133 | variables: %{} 134 | } 135 | }) 136 | 137 | WebsocketClient.send_message(socket, %OperationMessage{ 138 | type: "start", 139 | id: 2, 140 | payload: %{ 141 | query: "mutation submitPost($title: String, $body: String){ 142 | submitPost(title: $title, body: $body){ 143 | id 144 | body 145 | title 146 | 147 | } 148 | }", 149 | variables: %{title: "test title", body: "test body"} 150 | } 151 | }) 152 | 153 | assert_receive %{ 154 | "id" => 1, 155 | "payload" => %{ 156 | "data" => %{ 157 | "postAdded" => %{"body" => "test body", "id" => "1", "title" => "test title"} 158 | } 159 | }, 160 | "type" => "data" 161 | } 162 | end 163 | 164 | test "receives complete after unsubscribing a subscription" do 165 | {:ok, socket} = WebsocketClient.start_link(self(), @path, @json_module) 166 | 167 | WebsocketClient.send_message(socket, %OperationMessage{ 168 | type: "start", 169 | id: 1, 170 | payload: %{ 171 | query: "subscription { 172 | postAdded{ 173 | id 174 | body 175 | title 176 | 177 | } 178 | }", 179 | variables: %{} 180 | } 181 | }) 182 | 183 | WebsocketClient.send_message(socket, %OperationMessage{ 184 | type: "stop", 185 | id: 1 186 | }) 187 | 188 | assert_receive %{"id" => 1, "type" => "complete"} 189 | end 190 | 191 | test "terminates a connection subscription" do 192 | {:ok, socket} = WebsocketClient.start_link(self(), @path, @json_module) 193 | 194 | WebsocketClient.send_message(socket, %OperationMessage{ 195 | type: "start", 196 | id: 1, 197 | payload: %{ 198 | query: "subscription { 199 | postAdded{ 200 | id 201 | body 202 | title 203 | 204 | } 205 | }", 206 | variables: %{} 207 | } 208 | }) 209 | 210 | WebsocketClient.send_message(socket, %OperationMessage{ 211 | type: "connection_terminate" 212 | }) 213 | end 214 | 215 | test "starts keep alive messages" do 216 | {:ok, socket} = 217 | WebsocketClient.start_link( 218 | self(), 219 | "ws://127.0.0.1:#{@port}/ws/websocket", 220 | @json_module 221 | ) 222 | 223 | WebsocketClient.send_event(socket, ~s({ 224 | "type": "connection_init", 225 | "payload": { 226 | "token": "correct" 227 | } 228 | })) 229 | assert_receive %{"type" => "connection_ack"} 230 | assert_receive %{"type" => "ka"} 231 | end 232 | 233 | test "continues to receive keep alive messages" do 234 | {:ok, socket} = 235 | WebsocketClient.start_link( 236 | self(), 237 | "ws://127.0.0.1:#{@port}/ws/websocket", 238 | @json_module 239 | ) 240 | 241 | WebsocketClient.send_event(socket, ~s({ 242 | "type": "connection_init", 243 | "payload": { 244 | "token": "correct" 245 | } 246 | })) 247 | assert_receive %{"type" => "connection_ack"} 248 | 249 | assert_receive %{"type" => "ka"} 250 | assert_receive %{"type" => "ka"} 251 | end 252 | 253 | test "returns error for invalid graphql document" do 254 | {:ok, socket} = WebsocketClient.start_link(self(), @path, @json_module) 255 | 256 | WebsocketClient.send_message(socket, %OperationMessage{ 257 | type: "start", 258 | id: 1, 259 | payload: %{ 260 | query: "subscription { 261 | postAdded{ 262 | id 263 | doesNotExist 264 | } 265 | }", 266 | variables: %{} 267 | } 268 | }) 269 | 270 | assert_receive %{ 271 | "id" => 1, 272 | "payload" => %{ 273 | "errors" => [ 274 | %{ 275 | "locations" => [%{"column" => 13, "line" => 4}], 276 | "message" => "Cannot query field \"doesNotExist\" on type \"Post\"." 277 | } 278 | ], 279 | "data" => %{} 280 | }, 281 | "type" => "data" 282 | } 283 | end 284 | 285 | test "returns error for non-parseable variables subscription" do 286 | {:ok, socket} = WebsocketClient.start_link(self(), @path, @json_module) 287 | 288 | WebsocketClient.send_message(socket, %OperationMessage{ 289 | type: "start", 290 | id: 1, 291 | payload: %{ 292 | query: "subscription { 293 | postAdded{ 294 | id 295 | } 296 | }", 297 | variables: "" 298 | } 299 | }) 300 | 301 | assert_receive %{ 302 | "id" => 1, 303 | "payload" => %{"errors" => "Could not parse variables"}, 304 | "type" => "error" 305 | } 306 | end 307 | end 308 | -------------------------------------------------------------------------------- /test/subscriptions_transport_ws/operation_message_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SubscriptionsTransportWS.OperationMessageTest do 2 | use ExUnit.Case, async: true 3 | alias SubscriptionsTransportWS.OperationMessage 4 | doctest SubscriptionsTransportWS.OperationMessage 5 | end 6 | -------------------------------------------------------------------------------- /test/subscriptions_transport_ws/socket_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("../support/test_schema.exs", __DIR__) 2 | 3 | defmodule SubscriptionsTransportWS.Tests.SocketTest do 4 | use ExUnit.Case 5 | import ExUnit.CaptureLog 6 | 7 | import SubscriptionsTransportWS.SocketTest 8 | 9 | alias SubscriptionsTransportWS.OperationMessage 10 | 11 | @moduletag capture_log: true 12 | defmodule GraphqlSocket do 13 | use SubscriptionsTransportWS.Socket, schema: TestSchema, keep_alive: 1000 14 | 15 | @impl true 16 | def connect(params, socket) do 17 | send(self(), params) 18 | {:ok, socket} 19 | end 20 | 21 | @impl true 22 | def gql_connection_init(message, socket) do 23 | send(self(), message) 24 | {:ok, socket} 25 | end 26 | end 27 | 28 | defmodule Endpoint do 29 | use Phoenix.Endpoint, otp_app: :subscription_transport_ws 30 | use Absinthe.Phoenix.Endpoint 31 | 32 | socket("/ws", GraphqlSocket, websocket: [subprotocols: ["graphql-ws"]]) 33 | end 34 | 35 | @endpoint Endpoint 36 | 37 | Application.put_env(:subscription_transport_ws, Endpoint, pubsub_server: __MODULE__) 38 | 39 | setup_all do 40 | capture_log(fn -> start_supervised!(Endpoint) end) 41 | start_supervised!({Phoenix.PubSub, name: __MODULE__}) 42 | 43 | start_supervised!({Absinthe.Subscription, Endpoint}) 44 | 45 | :ok 46 | end 47 | 48 | test "socket/2" do 49 | assert %SubscriptionsTransportWS.Socket{} = socket(GraphqlSocket, TestSchema) 50 | end 51 | 52 | test "connect" do 53 | {:ok, _socket} = connect(GraphqlSocket, %{auth: "token"}) 54 | assert_receive %{"auth" => "token"} 55 | end 56 | 57 | test "connection_init" do 58 | {:ok, socket} = connect(GraphqlSocket, %{auth: "token"}) 59 | 60 | {:reply, :ok, {:text, "{\"type\":\"connection_ack\"}"}, _socket} = 61 | gql_connection_init(socket, %{a: 1}) 62 | 63 | assert_receive %OperationMessage{id: nil, payload: %{"a" => 1}, type: "connection_init"} 64 | end 65 | 66 | test "push_doc" do 67 | socket = socket(GraphqlSocket, TestSchema) 68 | 69 | {:ok, %{"data" => %{"posts" => [%{"body" => "body1", "id" => "aa"}]}}, _socket} = 70 | push_doc(socket, "query { 71 | posts { 72 | id 73 | body 74 | } 75 | }", variables: %{a: 1}) 76 | end 77 | 78 | test "push_doc with subscription" do 79 | socket = socket(GraphqlSocket, TestSchema) 80 | 81 | {:ok, %SubscriptionsTransportWS.Socket{}} = push_doc(socket, "subscription { 82 | postAdded{ 83 | id 84 | body 85 | title 86 | } 87 | }", variables: %{a: 1}) 88 | 89 | push_doc(socket, "mutation submitPost($title: String, $body: String){ 90 | submitPost(title: $title, body: $body){ 91 | id 92 | body 93 | title 94 | 95 | } 96 | }", variables: %{title: "test title", body: "test body"}) 97 | 98 | assert_receive_subscription(%{ 99 | "data" => %{ 100 | "postAdded" => %{"body" => "test body", "id" => "1", "title" => "test title"} 101 | } 102 | }) 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /test/support/test_schema.exs: -------------------------------------------------------------------------------- 1 | defmodule TestSchema do 2 | use Absinthe.Schema 3 | 4 | object :post do 5 | field(:id, :id) 6 | field(:title, :string) 7 | field(:body, :string) 8 | end 9 | 10 | query do 11 | @desc "Get all posts" 12 | field :posts, list_of(:post) do 13 | resolve(&list_posts/3) 14 | end 15 | end 16 | 17 | mutation do 18 | field :submit_post, :post do 19 | arg(:title, non_null(:string)) 20 | arg(:body, non_null(:string)) 21 | 22 | resolve(&submit_post/3) 23 | end 24 | end 25 | 26 | subscription do 27 | field :post_added, :post do 28 | config(fn _, _ -> 29 | {:ok, topic: "*"} 30 | end) 31 | 32 | trigger(:submit_post, 33 | topic: fn _ -> 34 | "*" 35 | end 36 | ) 37 | end 38 | end 39 | 40 | def submit_post(_parent, args, _resolution) do 41 | {:ok, 42 | %{ 43 | id: 1, 44 | title: args.title, 45 | body: args.body 46 | }} 47 | end 48 | 49 | def list_posts(_parent, _args, _resolution) do 50 | {:ok, 51 | [ 52 | %{ 53 | id: "aa", 54 | title: "title1", 55 | body: "body1" 56 | } 57 | ]} 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/support/websocket_client.exs: -------------------------------------------------------------------------------- 1 | defmodule SubscriptionsTransportWS.WebsocketClient do 2 | alias SubscriptionsTransportWS.OperationMessage 3 | 4 | @doc """ 5 | Starts the WebSocket server for given ws URL. Received Socket.Message's 6 | are forwarded to the sender pid 7 | """ 8 | def start_link(sender, url, json_module, headers \\ []) do 9 | :crypto.start() 10 | :ssl.start() 11 | 12 | :websocket_client.start_link( 13 | String.to_charlist(url), 14 | __MODULE__, 15 | [sender, json_module], 16 | extra_headers: headers 17 | ) 18 | end 19 | 20 | def init([sender, json_module], _conn_state) do 21 | {:ok, %{sender: sender, json_module: json_module}} 22 | end 23 | 24 | def send_message(server_pid, operation_message) do 25 | send(server_pid, {:send, OperationMessage.as_json(operation_message) |> Jason.encode!()}) 26 | end 27 | 28 | def send_event(server_pid, operation_message) do 29 | send(server_pid, {:send, operation_message}) 30 | end 31 | 32 | def websocket_handle({:text, msg}, _conn_state, state) do 33 | send(state.sender, state.json_module.decode!(msg)) 34 | 35 | {:ok, state} 36 | end 37 | 38 | def websocket_info({:send, msg}, _conn_state, state) do 39 | {:reply, {:text, msg}, state} 40 | end 41 | 42 | def websocket_info(:close, _conn_state, _state) do 43 | {:close, <<>>, "done"} 44 | end 45 | 46 | @doc """ 47 | Closes the socket 48 | """ 49 | def close(socket) do 50 | send(socket, :close) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------