diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..051d09d2 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +eval "$(lorri direnv)" diff --git a/.formatter.exs b/.formatter.exs index 525446d4..9ba91eb7 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,5 @@ # Used by "mix format" [ - inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] + inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"], + import_deps: [:stream_data] ] diff --git a/.gitignore b/.gitignore index c18811b2..f282b4ed 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ tortoise-*.tar # Files generated by Erlang/Elixir QuickCheck /current_counterexample.eqc /.eqc-info + +/todo.org \ No newline at end of file diff --git a/README.md b/README.md index a8585d9c..46ac4ba5 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ defmodule Tortoise.Handler.Example do {:ok, args} end - def connection(status, state) do + def status_change(status, state) do # `status` will be either `:up` or `:down`; you can use this to # inform the rest of your system if the connection is currently # open or closed; tortoise should be busy reconnecting if you get diff --git a/lib/tortoise.ex b/lib/tortoise.ex index 6a928923..ec6aea71 100644 --- a/lib/tortoise.ex +++ b/lib/tortoise.ex @@ -62,7 +62,6 @@ defmodule Tortoise do alias Tortoise.Package alias Tortoise.Connection - alias Tortoise.Connection.Inflight @typedoc """ An identifier used to identify the client on the server. @@ -147,6 +146,12 @@ defmodule Tortoise do """ @type topic() :: String.t() + # todo, documentation + @type topic_alias :: 0x0001..0xFFFF + + # todo, documentation + @type topic_or_topic_alias() :: topic() | topic_alias() + @typedoc """ A topic filter for a subscription. @@ -246,35 +251,59 @@ defmodule Tortoise do with `Tortoise` so it is easy to see where the message originated from. """ - @spec publish(client_id(), topic(), payload, [options]) :: - :ok | {:ok, reference()} | {:error, :unknown_connection} + @spec publish(Process.dest(), topic_or_topic_alias(), payload, [options]) :: + :ok | {:ok, reference()} | {:error, reason} when payload: binary() | nil, options: {:qos, qos()} | {:retain, boolean()} - | {:identifier, package_identifier()} - def publish(client_id, topic, payload \\ nil, opts \\ []) do + | {:identifier, package_identifier()}, + reason: :unknown_connection | :topic_alias_specified_twice + def publish(name_or_pid, topic, payload \\ nil, opts \\ []) + + def publish(name_or_pid, topic, payload, opts) when is_binary(topic) do + {opts, properties} = Keyword.split(opts, [:retain, :qos, :transforms]) qos = Keyword.get(opts, :qos, 0) publish = %Package.Publish{ topic: topic, qos: qos, payload: payload, - retain: Keyword.get(opts, :retain, false) + retain: Keyword.get(opts, :retain, false), + properties: properties } - with {:ok, {transport, socket}} <- Connection.connection(client_id) do - case publish do - %Package.Publish{qos: 0} -> + case publish do + %Package.Publish{qos: 0} -> + with {:ok, {transport, socket}} <- Connection.connection(name_or_pid) do encoded_publish = Package.encode(publish) apply(transport, :send, [socket, encoded_publish]) + else + {:error, :unknown_connection} -> + {:error, :unknown_connection} + end + + %Package.Publish{qos: qos} when qos in [1, 2] -> + # transforms = Keyword.get(opts, :transforms, {[], nil}) + Connection.publish(name_or_pid, publish) + end + end - %Package.Publish{qos: qos} when qos in [1, 2] -> - Inflight.track(client_id, {:outgoing, publish}) - end - else - {:error, :unknown_connection} -> - {:error, :unknown_connection} + # Support passing in a topic alias instead of a proper topic, in + # this case we will lift the topic alias into a topic_alias value in + # the property list, set the topic to an empty string and pass it on + # to the regular `publish/4` + def publish(client_id, topic_alias, payload, opts) + when is_integer(topic_alias) and topic_alias > 0 do + case Keyword.get(opts, :topic_alias) do + nil -> + publish(client_id, "", payload, Keyword.put(opts, :topic_alias, topic_alias)) + + ^topic_alias -> + publish(client_id, "", payload, opts) + + _otherwise -> + {:error, :topic_alias_specified_twice} end end @@ -304,37 +333,61 @@ defmodule Tortoise do See the documentation for `Tortoise.publish/4` for configuration. """ - @spec publish_sync(client_id(), topic(), payload, [options]) :: - :ok | {:error, :unknown_connection} + @spec publish_sync(Process.dest(), topic_or_topic_alias(), payload, [options]) :: + :ok | {:error, reason} when payload: binary() | nil, options: {:qos, qos()} | {:retain, boolean()} | {:identifier, package_identifier()} - | {:timeout, timeout()} - def publish_sync(client_id, topic, payload \\ nil, opts \\ []) do - timeout = Keyword.get(opts, :timeout, :infinity) + | {:timeout, timeout()}, + reason: :unknown_connection | :topic_alias_specified_twice + def publish_sync(pid_or_name, topic, payload \\ nil, opts \\ []) + + def publish_sync(pid_or_name, topic, payload, opts) when is_binary(topic) do + {opts, properties} = Keyword.split(opts, [:retain, :qos, :transforms, :timeout]) qos = Keyword.get(opts, :qos, 0) publish = %Package.Publish{ topic: topic, qos: qos, payload: payload, - retain: Keyword.get(opts, :retain, false) + retain: Keyword.get(opts, :retain, false), + properties: properties } - with {:ok, {transport, socket}} <- Connection.connection(client_id) do - case publish do - %Package.Publish{qos: 0} -> - encoded_publish = Package.encode(publish) - apply(transport, :send, [socket, encoded_publish]) + case publish do + # %Package.Publish{qos: 0} -> + # with {:ok, {transport, socket}} <- Connection.connection(client_id) do + # encoded_publish = Package.encode(publish) + # apply(transport, :send, [socket, encoded_publish]) + # else + # {:error, :unknown_connection} -> + # {:error, :unknown_connection} + # end + + %Package.Publish{qos: qos} when qos in [1, 2] -> + # transforms = Keyword.get(opts, :transforms, {[], nil}) + timeout = Keyword.get(opts, :timeout, :infinity) + Tortoise.Connection.publish_sync(pid_or_name, publish, timeout) + end + end - %Package.Publish{qos: qos} when qos in [1, 2] -> - Inflight.track_sync(client_id, {:outgoing, publish}, timeout) - end - else - {:error, :unknown_connection} -> - {:error, :unknown_connection} + # Support passing in a topic alias instead of a proper topic, in + # this case we will lift the topic alias into a topic_alias value in + # the property list, set the topic to an empty string and pass it on + # to the regular `publish_sync/4` + def publish_sync(client_id, topic_alias, payload, opts) + when is_integer(topic_alias) and topic_alias > 0 do + case Keyword.get(opts, :topic_alias) do + nil -> + publish(client_id, "", payload, Keyword.put(opts, :topic_alias, topic_alias)) + + ^topic_alias -> + publish(client_id, "", payload, opts) + + _otherwise -> + {:error, :topic_alias_specified_twice} end end end diff --git a/lib/tortoise/app.ex b/lib/tortoise/application.ex similarity index 75% rename from lib/tortoise/app.ex rename to lib/tortoise/application.ex index 89320647..c112dfdd 100644 --- a/lib/tortoise/app.ex +++ b/lib/tortoise/application.ex @@ -1,4 +1,4 @@ -defmodule Tortoise.App do +defmodule Tortoise.Application do @moduledoc false use Application @@ -10,7 +10,8 @@ defmodule Tortoise.App do children = [ {Registry, [keys: :unique, name: Tortoise.Registry]}, - {Registry, [keys: :duplicate, name: Tortoise.Events]}, + {Tortoise.TransmitterSupervisor, []}, + {Tortoise.Session, [backend: Tortoise.Session.Ets]}, {Tortoise.Supervisor, [strategy: :one_for_one]} ] diff --git a/lib/tortoise/connection.ex b/lib/tortoise/connection.ex index 6ae6d808..813a8f19 100644 --- a/lib/tortoise/connection.ex +++ b/lib/tortoise/connection.ex @@ -5,16 +5,28 @@ defmodule Tortoise.Connection do Todo. """ - use GenServer + use GenStateMachine require Logger - defstruct [:client_id, :connect, :server, :status, :backoff, :subscriptions, :keep_alive, :opts] + defstruct client_id: nil, + session: nil, + connect: nil, + server: nil, + backoff: nil, + opts: nil, + pending_refs: %{}, + connection: nil, + ping: {:idle, []}, + handler: nil, + receiver: nil, + info: nil + alias __MODULE__, as: State - alias Tortoise.{Transport, Connection, Package, Events} - alias Tortoise.Connection.{Inflight, Controller, Receiver, Backoff} - alias Tortoise.Package.{Connect, Connack} + alias Tortoise.{Handler, Transport, Package, Session} + alias Tortoise.Connection.{Info, Backoff} + alias Tortoise.Package.Connect @doc """ Start a connection process and link it to the current process. @@ -29,8 +41,6 @@ defmodule Tortoise.Connection do | {:password, String.t()} | {:keep_alive, non_neg_integer()} | {:will, Tortoise.Package.Publish.t()} - | {:subscriptions, - [{Tortoise.topic_filter(), Tortoise.qos()}] | Tortoise.Package.Subscribe.t()} | {:handler, {atom(), term()}}, options: [option] def start_link(connection_opts, opts \\ []) do @@ -44,35 +54,32 @@ defmodule Tortoise.Connection do keep_alive: Keyword.get(connection_opts, :keep_alive, 60), will: Keyword.get(connection_opts, :will), # if we re-spawn from here it means our state is gone - clean_session: true + clean_start: true } backoff = Keyword.get(connection_opts, :backoff, []) - # This allow us to either pass in a list of topics, or a - # subscription struct. Passing in a subscription struct is helpful - # in tests. - subscriptions = - case Keyword.get(connection_opts, :subscriptions, []) do - topics when is_list(topics) -> - Enum.into(topics, %Package.Subscribe{}) - - %Package.Subscribe{} = subscribe -> - subscribe - end - # @todo, validate that the handler is valid - connection_opts = Keyword.take(connection_opts, [:client_id, :handler]) - initial = {server, connect, backoff, subscriptions, connection_opts} - opts = Keyword.merge(opts, name: via_name(client_id)) - GenServer.start_link(__MODULE__, initial, opts) - end + handler = + connection_opts + |> Keyword.get(:handler, %Handler{module: Handler.Default, initial_args: []}) + |> Handler.new() + + connection_opts = [ + {:transport, server} | Keyword.take(connection_opts, [:client_id]) + ] + + initial = %State{ + client_id: connect.client_id, + session: %Session{client_id: connect.client_id}, + server: server, + connect: connect, + backoff: Backoff.new(backoff), + opts: connection_opts, + handler: handler + } - @doc false - @spec via_name(Tortoise.client_id()) :: - pid() | {:via, Registry, {Tortoise.Registry, {atom(), Tortoise.client_id()}}} - def via_name(client_id) do - Tortoise.Registry.via_name(__MODULE__, client_id) + GenStateMachine.start_link(__MODULE__, initial, opts) end @spec child_spec(Keyword.t()) :: %{ @@ -93,13 +100,22 @@ defmodule Tortoise.Connection do @doc """ Close the connection to the broker. - Given the `client_id` of a running connection it will cancel the - inflight messages and send the proper disconnect message to the - broker. The session will get terminated on the server. + Given the `pid` of a running connection it will cancel the inflight + messages and send the proper disconnect message to the broker. The + session will get terminated on the server. """ - @spec disconnect(Tortoise.client_id()) :: :ok - def disconnect(client_id) do - GenServer.call(via_name(client_id), :disconnect) + @spec disconnect(pid(), reason, properties) :: :ok + when reason: Tortoise.Package.Disconnect.reason(), + properties: [property], + property: + {:reason_string, String.t()} + | {:server_reference, String.t()} + | {:session_expiry_interval, 0..0xFFFFFFFF} + | {:user_property, {String.t(), String.t()}} + + def disconnect(pid, reason \\ :normal_disconnection, properties \\ []) do + disconnect = %Package.Disconnect{reason: reason, properties: properties} + GenStateMachine.call(pid, {:disconnect, disconnect}) end @doc """ @@ -108,9 +124,9 @@ defmodule Tortoise.Connection do Given the `client_id` of a running connection return its current subscriptions. This is helpful in a debugging situation. """ - @spec subscriptions(Tortoise.client_id()) :: Tortoise.Package.Subscribe.t() - def subscriptions(client_id) do - GenServer.call(via_name(client_id), :subscriptions) + @spec subscriptions(pid()) :: Tortoise.Package.Subscribe.t() + def subscriptions(pid) do + GenStateMachine.call(pid, :subscriptions) end @doc """ @@ -134,33 +150,39 @@ defmodule Tortoise.Connection do Read the documentation for `Tortoise.Connection.subscribe_sync/3` for a blocking version of this call. """ - @spec subscribe(Tortoise.client_id(), topic | topics, [options]) :: {:ok, reference()} + @spec subscribe(pid(), topic | topics, [options]) :: {:ok, {Tortoise.client_id(), reference()}} when topics: [topic], topic: {Tortoise.topic_filter(), Tortoise.qos()}, options: {:timeout, timeout()} | {:identifier, Tortoise.package_identifier()} - def subscribe(client_id, topics, opts \\ []) + def subscribe(pid, topics, opts \\ []) - def subscribe(client_id, [{_, n} | _] = topics, opts) when is_number(n) do - caller = {_, ref} = {self(), make_ref()} + def subscribe(pid, [{_, topic_opts} | _] = topics, opts) when is_list(topic_opts) do + # todo, do something with timeout, or remove it + {opts, properties} = Keyword.split(opts, [:identifier, :timeout]) {identifier, opts} = Keyword.pop_first(opts, :identifier, nil) - subscribe = Enum.into(topics, %Package.Subscribe{identifier: identifier}) - GenServer.cast(via_name(client_id), {:subscribe, caller, subscribe, opts}) - {:ok, ref} + + subscribe = + Enum.into(topics, %Package.Subscribe{ + identifier: identifier, + properties: properties + }) + + GenStateMachine.call(pid, {:subscribe, subscribe, opts}) end - def subscribe(client_id, {_, n} = topic, opts) when is_number(n) do - subscribe(client_id, [topic], opts) + def subscribe(pid, {_, topic_opts} = topic, opts) when is_list(topic_opts) do + subscribe(pid, [topic], opts) end - def subscribe(client_id, topic, opts) when is_binary(topic) do + def subscribe(pid, topic, opts) when is_binary(topic) do case Keyword.pop_first(opts, :qos) do {nil, _opts} -> throw("Please specify a quality of service for the subscription") {qos, opts} when qos in 0..2 -> - subscribe(client_id, [{topic, qos}], opts) + subscribe(pid, [{topic, [qos: qos]}], opts) end end @@ -176,38 +198,43 @@ defmodule Tortoise.Connection do See `Tortoise.Connection.subscribe/3` for configuration options. """ - @spec subscribe_sync(Tortoise.client_id(), topic | topics, [options]) :: + @spec subscribe_sync(pid(), topic | topics, [options]) :: :ok | {:error, :timeout} when topics: [topic], topic: {Tortoise.topic_filter(), Tortoise.qos()}, options: {:timeout, timeout()} | {:identifier, Tortoise.package_identifier()} - def subscribe_sync(client_id, topics, opts \\ []) + def subscribe_sync(pid, topics, opts \\ []) - def subscribe_sync(client_id, [{_, n} | _] = topics, opts) when is_number(n) do - timeout = Keyword.get(opts, :timeout, 5000) - {:ok, ref} = subscribe(client_id, topics, opts) + def subscribe_sync(pid, [{_, topic_opts} | _] = topics, opts) when is_list(topic_opts) do + case subscribe(pid, topics, opts) do + {:ok, {client_id, ref}} -> + timeout = Keyword.get(opts, :timeout, 5000) - receive do - {{Tortoise, ^client_id}, ^ref, result} -> result - after - timeout -> - {:error, :timeout} + receive do + {{Tortoise, ^client_id}, {Package.Suback, ^ref}, result} -> result + after + timeout -> + {:error, :timeout} + end + + {:error, _reason} = error -> + error end end - def subscribe_sync(client_id, {_, n} = topic, opts) when is_number(n) do - subscribe_sync(client_id, [topic], opts) + def subscribe_sync(pid, {_, topic_opts} = topic, opts) when is_list(topic_opts) do + subscribe_sync(pid, [topic], opts) end - def subscribe_sync(client_id, topic, opts) when is_binary(topic) do + def subscribe_sync(pid, topic, opts) when is_binary(topic) do case Keyword.pop_first(opts, :qos) do {nil, _opts} -> throw("Please specify a quality of service for the subscription") {qos, opts} -> - subscribe_sync(client_id, [{topic, qos}], opts) + subscribe_sync(pid, [{topic, qos: qos}], opts) end end @@ -221,24 +248,30 @@ defmodule Tortoise.Connection do This operation is asynchronous. When the operation is done a message will be received in mailbox of the originating process. """ - @spec unsubscribe(Tortoise.client_id(), topic | topics, [options]) :: {:ok, reference()} + @spec unsubscribe(pid(), topic | topics, [options]) :: + {:ok, {Tortoise.client_id(), reference()}} when topics: [topic], topic: Tortoise.topic_filter(), options: {:timeout, timeout()} | {:identifier, Tortoise.package_identifier()} - def unsubscribe(client_id, topics, opts \\ []) + def unsubscribe(pid, topics, opts \\ []) - def unsubscribe(client_id, [topic | _] = topics, opts) when is_binary(topic) do - caller = {_, ref} = {self(), make_ref()} + def unsubscribe(pid, [topic | _] = topics, opts) when is_binary(topic) do + {opts, properties} = Keyword.split(opts, [:identifier, :timeout]) {identifier, opts} = Keyword.pop_first(opts, :identifier, nil) - unsubscribe = %Package.Unsubscribe{identifier: identifier, topics: topics} - GenServer.cast(via_name(client_id), {:unsubscribe, caller, unsubscribe, opts}) - {:ok, ref} + + unsubscribe = %Package.Unsubscribe{ + identifier: identifier, + topics: topics, + properties: properties + } + + GenStateMachine.call(pid, {:unsubscribe, unsubscribe, opts}) end - def unsubscribe(client_id, topic, opts) when is_binary(topic) do - unsubscribe(client_id, [topic], opts) + def unsubscribe(pid, topic, opts) when is_binary(topic) do + unsubscribe(pid, [topic], opts) end @doc """ @@ -250,29 +283,52 @@ defmodule Tortoise.Connection do See `Tortoise.Connection.unsubscribe/3` for configuration options. """ - @spec unsubscribe_sync(Tortoise.client_id(), topic | topics, [options]) :: + @spec unsubscribe_sync(pid(), topic | topics, [options]) :: :ok | {:error, :timeout} when topics: [topic], topic: Tortoise.topic_filter(), options: {:timeout, timeout()} | {:identifier, Tortoise.package_identifier()} - def unsubscribe_sync(client_id, topics, opts \\ []) + def unsubscribe_sync(pid, topics, opts \\ []) - def unsubscribe_sync(client_id, topics, opts) when is_list(topics) do + def unsubscribe_sync(pid, topics, opts) when is_list(topics) do timeout = Keyword.get(opts, :timeout, 5000) - {:ok, ref} = unsubscribe(client_id, topics, opts) + {:ok, {client_id, ref}} = unsubscribe(pid, topics, opts) receive do - {{Tortoise, ^client_id}, ^ref, result} -> result + {{Tortoise, ^client_id}, {Package.Unsuback, ^ref}, result} -> + result after timeout -> {:error, :timeout} end end - def unsubscribe_sync(client_id, topic, opts) when is_binary(topic) do - unsubscribe_sync(client_id, [topic], opts) + def unsubscribe_sync(pid, topic, opts) when is_binary(topic) do + unsubscribe_sync(pid, [topic], opts) + end + + @doc """ + Publish a message, but go through the connection + + In most circumstances it would be preferable to go through the + publish function on the Tortoise module instead. + """ + def publish(pid, %Package.Publish{} = publish) do + GenStateMachine.call(pid, {:publish, publish}) + end + + def publish_sync(pid, %Package.Publish{} = publish, timeout \\ :infinity) do + {:ok, {client_id, ref}} = publish(pid, publish) + + receive do + {{Tortoise, ^client_id}, {Package.Publish, ^ref}, result} -> + result + after + timeout -> + {:error, :timeout} + end end @doc """ @@ -289,13 +345,15 @@ defmodule Tortoise.Connection do better to listen on `:ping_response` using the `Tortoise.Events` PubSub. """ - @spec ping(Tortoise.client_id()) :: {:ok, reference()} - defdelegate ping(client_id), to: Tortoise.Connection.Controller + @spec ping(pid(), timeout()) :: {:ok, reference()} + def ping(pid, timeout \\ :infinity) do + GenStateMachine.call(pid, :ping, timeout) + end @doc """ Ping the server and await the ping latency reply. - Takes a `client_id` and an optional `timeout`. + Takes a `pid` and an optional `timeout`. Like `ping/1` but will block the caller process until a response is received from the server. The response will contain the ping latency @@ -303,349 +361,969 @@ defmodule Tortoise.Connection do advisable to specify a reasonable time one is willing to wait for a response. """ - @spec ping_sync(Tortoise.client_id(), timeout()) :: {:ok, reference()} | {:error, :timeout} - defdelegate ping_sync(client_id, timeout \\ :infinity), - to: Tortoise.Connection.Controller - - @doc false - @spec connection(Tortoise.client_id(), [opts]) :: - {:ok, {module(), term()}} | {:error, :unknown_connection} | {:error, :timeout} - when opts: {:timeout, timeout()} | {:active, boolean()} - def connection(client_id, opts \\ [active: false]) do - # register a connection subscription in the case we are currently - # in the connect phase; this solves a possible race condition - # where the connection is requested while the status is - # connecting, but will reach the receive block after the message - # has been dispatched from the pubsub; previously we registered - # for the connection message in this window. - {:ok, _} = Events.register(client_id, :connection) - - case Tortoise.Registry.meta(via_name(client_id)) do - {:ok, {_transport, _socket} = connection} -> - {:ok, connection} - - {:ok, :connecting} -> - timeout = Keyword.get(opts, :timeout, :infinity) - + @spec ping_sync(pid(), timeout()) :: {:ok, reference()} | {:error, :timeout} + def ping_sync(pid, timeout \\ :infinity) do + case ping(pid, timeout) do + {:ok, {client_id, ref}} -> receive do - {{Tortoise, ^client_id}, :connection, {transport, socket}} -> - {:ok, {transport, socket}} + {{Tortoise, ^client_id}, {Package.Pingreq, ^ref}, round_trip_time} -> + {:ok, round_trip_time} after timeout -> {:error, :timeout} end - :error -> - {:error, :unknown_connection} + {:error, _reason} = error -> + error + end + end + + @doc """ + Get the info on the current connection configuration + """ + def info(pid) do + GenStateMachine.call(pid, :get_info) + end + + @doc false + @spec connection(pid(), [opts]) :: + {:ok, {module(), term()}} | {:error, :unknown_connection} | {:error, :timeout} + when opts: {:timeout, timeout()} | {:active, boolean()} + def connection(pid, _opts \\ [active: false]) + + def connection(name_or_pid, opts) do + timeout = Keyword.get(opts, :timeout, :infinity) + + # TODO make it possible to subscribe to a connection using "active"! + if GenServer.whereis(name_or_pid) |> Process.alive?() do + GenStateMachine.call(name_or_pid, :get_connection, timeout) + else + {:error, :unknown_connection} end - after - # if the connection subscription is non-active we should remove it - # from the registry, so the process will not receive connection - # messages when the connection is reestablished. - active? = Keyword.get(opts, :active, false) - unless active?, do: Events.unregister(client_id, :connection) end + # def connection(client_id, opts) do + # # register a connection subscription in the case we are currently + # # in the connect phase; this solves a possible race condition + # # where the connection is requested while the status is + # # connecting, but will reach the receive block after the message + # # has been dispatched from the pubsub; previously we registered + # # for the connection message in this window. + # {:ok, _} = Events.register(client_id, :connection) + + # case Tortoise.Registry.meta(via_name(client_id)) do + # {:ok, {_transport, _socket} = connection} -> + # {:ok, connection} + + # {:ok, :connecting} -> + # timeout = Keyword.get(opts, :timeout, :infinity) + + # receive do + # {{Tortoise, ^client_id}, :connection, {transport, socket}} -> + # {:ok, {transport, socket}} + # after + # timeout -> + # {:error, :timeout} + # end + + # :error -> + # {:error, :unknown_connection} + # end + # after + # # if the connection subscription is non-active we should remove it + # # from the registry, so the process will not receive connection + # # messages when the connection is reestablished. + # active? = Keyword.get(opts, :active, false) + # unless active?, do: Events.unregister(client_id, :connection) + # end + # Callbacks @impl true - def init( - {transport, %Connect{client_id: client_id} = connect, backoff_opts, subscriptions, opts} - ) do - state = %State{ - client_id: client_id, - server: transport, - connect: connect, - backoff: Backoff.new(backoff_opts), - subscriptions: subscriptions, - opts: opts, - status: :down - } + def init(%State{} = state) do + case Handler.execute_init(state.handler) do + {:ok, %Handler{} = updated_handler} -> + updated_state = %State{state | handler: updated_handler} + + transition_actions = [ + {:next_event, :internal, :connect} + ] + + {:ok, :connecting, updated_state, transition_actions} - Tortoise.Registry.put_meta(via_name(client_id), :connecting) - Tortoise.Events.register(client_id, :status) + :ignore -> + :ignore - # eventually, switch to handle_continue - send(self(), :connect) - {:ok, state} + {:stop, reason} -> + {:stop, reason} + end end @impl true - def terminate(_reason, state) do - :ok = Tortoise.Registry.delete_meta(via_name(state.connect.client_id)) - :ok = Events.dispatch(state.client_id, :status, :terminated) - :ok + def terminate(reason, _state, %State{handler: handler}) do + _ignored = + if function_exported?(handler.module, :terminate, 2) do + Handler.execute_terminate(handler, reason) + end end @impl true - def handle_info(:connect, state) do - # make sure we will not fall for a keep alive timeout while we reconnect - state = cancel_keep_alive(state) - - with {%Connack{status: :accepted} = connack, socket} <- - do_connect(state.server, state.connect), - {:ok, state} = init_connection(socket, state) do - # we are connected; reset backoff state, etc - state = - %State{state | backoff: Backoff.reset(state.backoff)} - |> update_connection_status(:up) - |> reset_keep_alive() - - case connack do - %Connack{session_present: true} -> - {:noreply, state} - - %Connack{session_present: false} -> - :ok = Inflight.reset(state.client_id) - unless Enum.empty?(state.subscriptions), do: send(self(), :subscribe) - {:noreply, state} - end - else - %Connack{status: {:refused, reason}} -> - {:stop, {:connection_failed, reason}, state} + def handle_event(:info, {:incoming, package}, _, _data) when is_binary(package) do + next_actions = [{:next_event, :internal, {:received, Package.decode(package)}}] + {:keep_state_and_data, next_actions} + end - {:error, reason} -> - {timeout, state} = Map.get_and_update(state, :backoff, &Backoff.next/1) + # connection acknowledgement + def handle_event( + :internal, + {:received, %Package.Connack{reason: connection_result} = connack}, + :connecting, + %State{ + connect: %Package.Connect{} = connect, + handler: handler + } = data + ) do + case Handler.execute_handle_connack(handler, connack) do + {:ok, %Handler{} = updated_handler, next_actions} when connection_result == :success -> + data = %State{ + data + | backoff: Backoff.reset(data.backoff), + handler: updated_handler, + info: Info.merge(connect, connack) + } - case categorize_error(reason) do - :connectivity -> - Process.send_after(self(), :connect, timeout) - {:noreply, state} + next_actions = [ + {:next_event, :internal, :setup_keep_alive_timer}, + {:next_event, :internal, {:execute_handler, {:connection, :up}}} + | wrap_next_actions(next_actions) + ] - :other -> - {:stop, reason, state} - end + {:next_state, :connected, data, next_actions} + + {:stop, reason, %Handler{} = updated_handler} -> + data = %State{data | handler: updated_handler} + {:stop, reason, data} + + {:error, reason} -> + {:stop, reason, data} end end - def handle_info(:subscribe, %State{subscriptions: subscriptions} = state) do - client_id = state.connect.client_id + def handle_event( + :internal, + {:received, package}, + :connecting, + %State{} = data + ) do + reason = %{expected: [Package.Connack, Package.Auth], got: package} + {:stop, {:protocol_violation, reason}, data} + end - case Enum.empty?(subscriptions) do - true -> - # nothing to subscribe to, just continue - {:noreply, state} + # get status ========================================================= + def handle_event({:call, from}, :get_info, :connected, %{receiver: {receiver_pid, _}} = data) do + next_actions = [{:reply, from, {:connected, struct(data.info, receiver_pid: receiver_pid)}}] - false -> - # subscribe to the predefined topics - case Inflight.track_sync(client_id, {:outgoing, subscriptions}, 5000) do - {:error, :timeout} -> - {:stop, :subscription_timeout, state} + {:keep_state_and_data, next_actions} + end - result -> - case handle_suback_result(result, state) do - {:ok, updated_state} -> - {:noreply, updated_state} + def handle_event({:call, from}, :get_info, state, _data) do + next_actions = [{:reply, from, state}] - {:error, reasons} -> - error = {:unable_to_subscribe, reasons} - {:stop, error, state} - end - end - end + {:keep_state_and_data, next_actions} end - def handle_info(:ping, %State{} = state) do - case Controller.ping_sync(state.connect.client_id, 5000) do - {:ok, round_trip_time} -> - Events.dispatch(state.connect.client_id, :ping_response, round_trip_time) - state = reset_keep_alive(state) - {:noreply, state} + # a process request the connection; postpone if we are not yet connected + def handle_event({:call, _from}, :get_connection, :connecting, _data) do + {:keep_state_and_data, [:postpone]} + end - {:error, :timeout} -> - {:stop, :ping_timeout, state} + def handle_event({:call, from}, :get_connection, :connected, data) do + transition_actions = [{:reply, from, {:ok, data.connection}}] + {:keep_state, data, transition_actions} + end + + # publish packages =================================================== + def handle_event( + :internal, + {:received, %Package.Publish{qos: 0, dup: false} = publish}, + _, + %State{handler: handler} = data + ) do + case Handler.execute_handle_publish(handler, publish) do + {:ok, %Handler{} = updated_handler, next_actions} -> + updated_data = %State{data | handler: updated_handler} + {:keep_state, updated_data, wrap_next_actions(next_actions)} + + {:error, reason} -> + {:stop, reason, data} end end - # dropping connection - def handle_info({transport, _socket}, state) when transport in [:tcp_closed, :ssl_closed] do - Logger.error("Socket closed before we handed it to the receiver") - # communicate that we are down - :ok = Events.dispatch(state.client_id, :status, :down) - {:noreply, state} + # incoming publish QoS=1 --------------------------------------------- + def handle_event( + :internal, + {:received, %Package.Publish{identifier: id, qos: 1} = publish}, + _, + %State{ + connection: {transport, socket}, + handler: handler, + session: session + } = data + ) do + case Session.track(session, {:incoming, publish}) do + {{:cont, publish}, session} -> + case Handler.execute_handle_publish(handler, publish) do + {:ok, %Package.Puback{identifier: ^id} = puback, updated_handler, next_actions} -> + # respond with a puback + {{:cont, _puback}, session} = Session.progress(session, {:outgoing, puback}) + :ok = transport.send(socket, Package.encode(puback)) + {:ok, session} = Session.release(session, id) + # - - - + updated_data = %State{data | handler: updated_handler, session: session} + {:keep_state, updated_data, wrap_next_actions(next_actions)} + + # handle stop + end + end end - # react to connection status change events - def handle_info( - {{Tortoise, client_id}, :status, status}, - %{client_id: client_id, status: current} = state + # outgoing publish QoS=1 --------------------------------------------- + def handle_event( + {:call, {_caller_pid, ref} = from}, + {:publish, %Package.Publish{qos: 1} = publish}, + _, + %State{ + connection: {transport, socket}, + session: session, + pending_refs: pending + } = data ) do - case status do - ^current -> - {:noreply, state} + case Session.track(session, {:outgoing, publish}) do + {{:cont, %Package.Publish{identifier: id} = publish}, session} -> + :ok = transport.send(socket, Package.encode(publish)) + next_actions = [{:reply, from, {:ok, {session.client_id, ref}}}] + data = %State{data | session: session, pending_refs: Map.put_new(pending, id, from)} + {:keep_state, data, next_actions} + end + end - :up -> - {:noreply, %State{state | status: status}} + def handle_event( + :internal, + {:received, %Package.Puback{identifier: id} = puback}, + _, + %State{session: session, handler: handler, pending_refs: pending} = data + ) do + case Map.pop(pending, id) do + {caller, pending} -> + {{:cont, puback}, session} = Session.progress(session, {:incoming, puback}) + data = %State{data | pending_refs: pending, session: session} + + if function_exported?(handler.module, :handle_puback, 2) do + case Handler.execute_handle_puback(handler, puback) do + {:ok, %Handler{} = updated_handler, next_actions} -> + next_actions = [ + {:next_event, :internal, {:reply, caller, Package.Publish, :ok}} + | wrap_next_actions(next_actions) + ] + + {:ok, session} = Session.release(session, id) + updated_data = %State{data | handler: updated_handler, session: session} + {:keep_state, updated_data, next_actions} + + {:error, reason} -> + # todo + updated_data = %State{data | session: session} + {:stop, reason, updated_data} + end + else + {:ok, session} = Session.release(session, id) + next_actions = [{:next_event, :internal, {:reply, caller, Package.Publish, :ok}}] + {:keep_state, %State{data | session: session}, next_actions} + end + end + end - :down -> - send(self(), :connect) - {:noreply, %State{state | status: status}} + # incoming publish QoS=2 --------------------------------------------- + # TODO handle duplicate messages + def handle_event( + :internal, + {:received, %Package.Publish{qos: 2, identifier: id} = publish}, + _, + %State{connection: {transport, socket}, handler: handler, session: session} = data + ) do + case Session.track(session, {:incoming, publish}) do + {{:cont, publish}, session} -> + case Handler.execute_handle_publish(handler, publish) do + {:ok, %Package.Pubrec{identifier: ^id} = pubrec, %Handler{} = updated_handler, + next_actions} -> + # respond with pubrec + {{:cont, pubrec}, session} = Session.progress(session, {:outgoing, pubrec}) + :ok = transport.send(socket, Package.encode(pubrec)) + # - - - + updated_data = %State{data | handler: updated_handler, session: session} + {:keep_state, updated_data, wrap_next_actions(next_actions)} + end end end - @impl true - def handle_call(:subscriptions, _from, state) do - {:reply, state.subscriptions, state} + def handle_event( + :internal, + {:received, %Package.Pubrel{identifier: id} = pubrel}, + _, + %State{connection: {transport, socket}, handler: handler, session: session} = data + ) do + {{:cont, pubrel}, session} = Session.progress(session, {:incoming, pubrel}) + + if function_exported?(handler.module, :handle_pubrel, 2) do + case Handler.execute_handle_pubrel(handler, pubrel) do + {:ok, %Package.Pubcomp{identifier: ^id} = pubcomp, %Handler{} = updated_handler, + next_actions} -> + # dispatch the pubcomp + {{:cont, pubcomp}, session} = Session.progress(session, {:outgoing, pubcomp}) + :ok = transport.send(socket, Package.encode(pubcomp)) + {:ok, session} = Session.release(session, id) + updated_data = %State{data | handler: updated_handler, session: session} + {:keep_state, updated_data, wrap_next_actions(next_actions)} + + {:error, reason} -> + # todo + {:stop, reason, data} + end + else + pubcomp = %Package.Pubcomp{identifier: id} + {{:cont, pubcomp}, session} = Session.progress(session, {:outgoing, pubcomp}) + :ok = transport.send(socket, Package.encode(pubcomp)) + {:ok, session} = Session.release(session, id) + updated_data = %State{data | session: session} + {:keep_state, updated_data} + end end - def handle_call(:disconnect, from, state) do - :ok = Events.dispatch(state.client_id, :status, :terminating) - :ok = Inflight.drain(state.client_id) - :ok = Controller.stop(state.client_id) - :ok = GenServer.reply(from, :ok) - {:stop, :shutdown, state} + # outgoing publish QoS=2 --------------------------------------------- + def handle_event( + {:call, {_caller_pid, ref} = from}, + {:publish, %Package.Publish{qos: 2, dup: false} = publish}, + _, + %State{ + connection: {transport, socket}, + session: session, + pending_refs: pending + } = data + ) do + case Session.track(session, {:outgoing, publish}) do + {{:cont, %Package.Publish{identifier: id} = publish}, session} -> + :ok = transport.send(socket, Package.encode(publish)) + next_actions = [{:reply, from, {:ok, {session.client_id, ref}}}] + data = %State{data | session: session, pending_refs: Map.put_new(pending, id, from)} + {:keep_state, data, next_actions} + end end - @impl true - def handle_cast({:subscribe, {caller_pid, ref}, subscribe, opts}, state) do - client_id = state.connect.client_id - timeout = Keyword.get(opts, :timeout, 5000) + def handle_event( + :internal, + {:received, %Package.Pubrec{identifier: id, reason: reason} = pubrec}, + _, + %State{connection: {transport, socket}, session: session, handler: handler} = data + ) do + {{:cont, pubrec}, session} = Session.progress(session, {:incoming, pubrec}) + data = %State{data | session: session} + + if function_exported?(handler.module, :handle_pubrec, 2) do + case Handler.execute_handle_pubrec(handler, pubrec) do + {:ok, %Package.Pubrel{identifier: ^id} = pubrel, %Handler{} = updated_handler, + next_actions} + when reason in [:success, {:refused, :no_matching_subscribers}] -> + # NOTICE that we do allow the "no matching subscribers" + # reason as a success; "...in the case of QoS 2 PUBLISH it + # is PUBCOMP or a PUBREC with a Reason Code of 128 or + # greater" + {{:cont, pubrel}, session} = Session.progress(session, {:outgoing, pubrel}) + :ok = transport.send(socket, Package.encode(pubrel)) + data = %State{data | session: session, handler: updated_handler} + {:keep_state, data, wrap_next_actions(next_actions)} + + {:ok, %Package.Pubrel{}, _updated_handler, _} -> + # user error; should not respond with pubrel if the publish failed + # TODO add a test case returning ok-pubrel on rejected pubrec + {:ok, session} = Session.release(session, id) + data = %State{data | session: session} + {:stop, reason, data} + + {:error, reason} -> + # todo + {:stop, reason, data} + end + else + if reason in [:success, {:refused, :no_matching_subscribers}] do + pubrel = %Package.Pubrel{identifier: id} + {{:cont, pubrel}, session} = Session.progress(session, {:outgoing, pubrel}) + :ok = transport.send(socket, Package.encode(pubrel)) + data = %State{data | session: session} + {:keep_state, data} + else + # "The Packet Identifier becomes available for reuse once the + # sender has received the PUBCOMP packet *or a PUBREC with a + # Reason Code of 0x80 or greater.*" + {:ok, session} = Session.release(session, id) + data = %State{data | session: session} + # TODO; Reply an error-tuple to the caller + {:keep_state, data} + end + end + end - case Inflight.track_sync(client_id, {:outgoing, subscribe}, timeout) do - {:error, :timeout} = error -> - send(caller_pid, {{Tortoise, client_id}, ref, error}) - {:noreply, state} - - result -> - case handle_suback_result(result, state) do - {:ok, updated_state} -> - send(caller_pid, {{Tortoise, client_id}, ref, :ok}) - {:noreply, updated_state} - - {:error, reasons} -> - error = {:unable_to_subscribe, reasons} - send(caller_pid, {{Tortoise, client_id}, ref, {:error, reasons}}) - {:stop, error, state} + def handle_event( + :internal, + {:received, %Package.Pubcomp{identifier: id} = pubcomp}, + :connected, + %State{session: session, pending_refs: pending, handler: handler} = data + ) do + case Map.pop(pending, id) do + {caller, pending} -> + {{:cont, pubcomp}, session} = Session.progress(session, {:incoming, pubcomp}) + data = %State{data | pending_refs: pending, session: session} + + if function_exported?(handler.module, :handle_pubcomp, 2) do + case Handler.execute_handle_pubcomp(handler, pubcomp) do + {:ok, %Handler{} = updated_handler, next_actions} -> + {:ok, session} = Session.release(session, id) + + data = %State{data | session: session, handler: updated_handler} + + next_actions = [ + {:next_event, :internal, {:reply, caller, Package.Publish, :ok}} + | wrap_next_actions(next_actions) + ] + + {:keep_state, data, next_actions} + + {:error, reason} -> + # todo + {:stop, reason, data} + end + else + {:ok, session} = Session.release(session, id) + data = %State{data | session: session} + next_actions = [{:next_event, :internal, {:reply, caller, Package.Publish, :ok}}] + {:keep_state, data, next_actions} end end end - def handle_cast({:unsubscribe, {caller_pid, ref}, unsubscribe, opts}, state) do - client_id = state.connect.client_id - timeout = Keyword.get(opts, :timeout, 5000) + # subscription logic + def handle_event( + {:call, from}, + {:subscribe, %Package.Subscribe{topics: []}, _opts}, + :connected, + _data + ) do + # This should not really be able to happen as the API will not + # allow the user to specify an empty list, but this is added for + # good measure + next_actions = [{:reply, from, {:error, :empty_topic_filter_list}}] + {:keep_state_and_data, next_actions} + end - case Inflight.track_sync(client_id, {:outgoing, unsubscribe}, timeout) do - {:error, :timeout} = error -> - send(caller_pid, {{Tortoise, client_id}, ref, error}) - {:noreply, state} + def handle_event( + {:call, {_, ref} = from}, + {:subscribe, %Package.Subscribe{} = subscribe, _opts}, + :connected, + %State{ + connection: {transport, socket}, + session: session + } = data + ) do + case Info.Capabilities.validate(data.info.capabilities, subscribe) do + :valid -> + case Session.track(session, {:outgoing, subscribe}) do + {{:cont, %Package.Subscribe{identifier: id} = subscribe}, session} -> + :ok = transport.send(socket, Package.encode(subscribe)) + pending = Map.put_new(data.pending_refs, id, {from, subscribe}) + state = %State{data | pending_refs: pending, session: session} + next_actions = [{:reply, from, {:ok, {session.client_id, ref}}}] + {:keep_state, state, next_actions} + end - unsubbed -> - topics = Keyword.drop(state.subscriptions.topics, unsubbed) - subscriptions = %Package.Subscribe{state.subscriptions | topics: topics} - send(caller_pid, {{Tortoise, client_id}, ref, :ok}) - {:noreply, %State{state | subscriptions: subscriptions}} + {:invalid, reasons} -> + reply = {:error, {:subscription_failure, reasons}} + next_actions = [{:reply, from, reply}] + {:keep_state_and_data, next_actions} end end - # Helpers - defp handle_suback_result(%{:error => []} = results, %State{} = state) do - subscriptions = Enum.into(results[:ok], state.subscriptions) - {:ok, %State{state | subscriptions: subscriptions}} + def handle_event({:call, _}, {:subscribe, _, _}, _state_name, _data) do + {:keep_state_and_data, [:postpone]} end - defp handle_suback_result(%{:error => errors}, %State{}) do - {:error, errors} - end + def handle_event( + :internal, + {:subscribe, %Package.Subscribe{} = subscribe, _opts}, + :connected, + %State{ + connection: {transport, socket}, + session: session + } = data + ) do + case Info.Capabilities.validate(data.info.capabilities, subscribe) do + :valid -> + case Session.track(session, {:outgoing, subscribe}) do + {{:cont, %Package.Subscribe{identifier: id} = subscribe}, session} -> + caller = {self(), make_ref()} + :ok = transport.send(socket, Package.encode(subscribe)) + pending = Map.put_new(data.pending_refs, id, {caller, subscribe}) + {:keep_state, %State{data | pending_refs: pending, session: session}} + end - defp reset_keep_alive(%State{keep_alive: nil} = state) do - ref = Process.send_after(self(), :ping, state.connect.keep_alive * 1000) - %State{state | keep_alive: ref} + {:invalid, reasons} -> + next_actions = [{:error, {:subscription_failure, reasons}}] + {:keep_state_and_data, next_actions} + end end - defp reset_keep_alive(%State{keep_alive: previous_ref} = state) do - # Cancel the previous timer, just in case one was already set - _ = Process.cancel_timer(previous_ref) - ref = Process.send_after(self(), :ping, state.connect.keep_alive * 1000) - %State{state | keep_alive: ref} + def handle_event(:internal, {:subscribe, _, _}, _state_name, _data) do + {:keep_state_and_data, [:postpone]} end - defp cancel_keep_alive(%State{keep_alive: nil} = state) do - state + def handle_event( + :internal, + {:received, %Package.Suback{identifier: id} = suback}, + :connected, + %State{session: session, handler: handler, pending_refs: pending, info: info} = data + ) do + case Map.pop(pending, id) do + {{caller, %Package.Subscribe{identifier: ^id} = subscribe}, pending} -> + {pid, msg_ref} = caller + + {{:cont, suback}, session} = Session.progress(session, {:incoming, suback}) + + data = %State{data | pending_refs: pending, session: session} + + updated_subscriptions = + subscribe.topics + |> Enum.zip(suback.acks) + |> Enum.reduce(data.info.subscriptions, fn + {{topic, opts}, {:ok, accepted_qos}}, acc -> + Map.put(acc, topic, Keyword.replace!(opts, :qos, accepted_qos)) + + {_, {:error, _}}, acc -> + acc + end) + + case Handler.execute_handle_suback(handler, subscribe, suback) do + {:ok, %Handler{} = updated_handler, next_actions} -> + data = %State{ + data + | handler: updated_handler, + info: put_in(info.subscriptions, updated_subscriptions) + } + + next_actions = [ + {:next_event, :internal, {:reply, {pid, msg_ref}, Package.Suback, :ok}} + | wrap_next_actions(next_actions) + ] + + {:ok, session} = Session.release(session, id) + + {:keep_state, %State{data | session: session}, next_actions} + + {:error, reason} -> + # todo + {:stop, reason, data} + end + end end - defp cancel_keep_alive(%State{keep_alive: keep_alive_ref} = state) do - _ = Process.cancel_timer(keep_alive_ref) - %State{state | keep_alive: nil} + def handle_event( + {:call, {_pid, ref} = from}, + {:unsubscribe, unsubscribe, opts}, + :connected, + %State{ + session: session, + connection: {transport, socket}, + pending_refs: pending + } = data + ) do + _timeout = Keyword.get(opts, :timeout, 5000) + + case Session.track(session, {:outgoing, unsubscribe}) do + {{:cont, %Package.Unsubscribe{identifier: id} = unsubscribe}, session} -> + :ok = transport.send(socket, Package.encode(unsubscribe)) + pending = Map.put_new(pending, id, {from, unsubscribe}) + next_actions = [{:reply, from, {:ok, {session.client_id, ref}}}] + {:keep_state, %State{data | pending_refs: pending, session: session}, next_actions} + end end - # dispatch connection status if the connection status change - defp update_connection_status(%State{status: same} = state, same) do - state + def handle_event( + :internal, + {:unsubscribe, unsubscribe, opts}, + :connected, + %State{ + session: session, + connection: {transport, socket}, + pending_refs: pending + } = data + ) do + _timeout = Keyword.get(opts, :timeout, 5000) + + case Session.track(session, {:outgoing, unsubscribe}) do + {{:cont, %Package.Unsubscribe{identifier: id} = unsubscribe}, session} -> + :ok = transport.send(socket, Package.encode(unsubscribe)) + caller = {self(), make_ref()} + pending = Map.put_new(pending, id, {caller, unsubscribe}) + {:keep_state, %State{data | pending_refs: pending, session: session}} + end end - defp update_connection_status(%State{} = state, status) do - :ok = Events.dispatch(state.connect.client_id, :status, status) - %State{state | status: status} + def handle_event( + :internal, + {:received, %Package.Unsuback{identifier: id, results: [_ | _]} = unsuback}, + :connected, + %State{session: session, handler: handler, pending_refs: pending, info: info} = data + ) do + case Map.pop(pending, id) do + {{caller, %Package.Unsubscribe{identifier: ^id} = unsubscribe}, pending} -> + {pid, msg_ref} = caller + {{:cont, unsuback}, session} = Session.progress(session, {:incoming, unsuback}) + data = %State{data | pending_refs: pending, session: session} + + # When updating the internal subscription state tracker we will + # disregard the unsuccessful unsubacks, as we can assume it wasn't + # in the subscription list to begin with, or that we are still + # subscribed as we are not autorized to unsubscribe for the given + # topic; one exception is when the server report no subscription + # existed; then we will update the client state + to_remove = + for {topic, result} <- Enum.zip(unsubscribe.topics, unsuback.results), + match?( + reason when reason == :success or reason == {:error, :no_subscription_existed}, + result + ), + do: topic + + # TODO handle the unsuback error cases ! + subscriptions = Map.drop(data.info.subscriptions, to_remove) + + case Handler.execute_handle_unsuback(handler, unsubscribe, unsuback) do + {:ok, %Handler{} = updated_handler, next_actions} -> + {:ok, session} = Session.release(session, id) + + data = %State{ + data + | handler: updated_handler, + session: session, + info: put_in(info.subscriptions, subscriptions) + } + + next_actions = [ + {:next_event, :internal, {:reply, {pid, msg_ref}, Package.Unsuback, :ok}} + | wrap_next_actions(next_actions) + ] + + {:keep_state, data, next_actions} + + {:error, reason} -> + # todo + {:stop, reason, data} + end + end end - defp do_connect(server, %Connect{} = connect) do - %Transport{type: transport, host: host, port: port, opts: opts} = server + # Pass on the result of an operation if we have a calling pid. This + # can happen if a process order the connection to subscribe to a + # topic, or unsubscribe, etc. + def handle_event( + :internal, + {:reply, caller, topic, payload}, + _current_state, + %State{session: session} + ) do + case caller do + {pid, _ref} when pid != self() -> + _ = send_reply(session.client_id, caller, topic, payload) + :keep_state_and_data + + _otherwise -> + :keep_state_and_data + end + end - with {:ok, socket} <- transport.connect(host, port, opts, 10000), - :ok = transport.send(socket, Package.encode(connect)), - {:ok, packet} <- transport.recv(socket, 4, 5000) do - try do - case Package.decode(packet) do - %Connack{status: :accepted} = connack -> - {connack, socket} + def handle_event({:call, from}, :subscriptions, _, %State{ + info: %Info{subscriptions: subscriptions} + }) do + next_actions = [{:reply, from, subscriptions}] + {:keep_state_and_data, next_actions} + end - %Connack{status: {:refused, _reason}} = connack -> - connack + # User actions are actions returned by the user defined callbacks; + # They inform the connection to perform an action, such as + # subscribing to a topic, and they are validated by the handler + # module, so there is no need to coerce here + def handle_event( + :internal, + {:user_action, action}, + _, + %State{connection: {transport, socket}} = state + ) do + case action do + {:subscribe, topic, opts} when is_binary(topic) -> + {identifier, opts} = Keyword.pop_first(opts, :identifier, nil) + subscribe = %Package.Subscribe{identifier: identifier, topics: [{topic, opts}]} + next_actions = [{:next_event, :internal, {:subscribe, subscribe, opts}}] + {:keep_state_and_data, next_actions} + + {:unsubscribe, topic, opts} when is_binary(topic) -> + {identifier, opts} = Keyword.pop_first(opts, :identifier, nil) + subscribe = %Package.Unsubscribe{identifier: identifier, topics: [topic]} + next_actions = [{:next_event, :internal, {:unsubscribe, subscribe, opts}}] + {:keep_state_and_data, next_actions} + + :disconnect -> + disconnect = %Package.Disconnect{reason: :normal_disconnection} + # TODO consider draining messages with qos + :ok = transport.send(socket, Package.encode(disconnect)) + {:stop, :normal} + + {:eval, fun} when is_function(fun, 1) -> + try do + apply(fun, [state]) + rescue + _disregard -> nil end - catch - :error, {:badmatch, _unexpected} -> - violation = %{expected: Connect, got: packet} - {:error, {:protocol_violation, violation}} + + {:keep_state, state} + end + end + + # dispatch to user defined handler + def handle_event( + :internal, + {:execute_handler, {:connection, status}}, + :connected, + %State{handler: handler} = data + ) do + if function_exported?(handler.module, :status_change, 2) do + case Handler.execute_status_change(handler, status) do + {:ok, %Handler{} = updated_handler, next_actions} -> + updated_data = %State{data | handler: updated_handler} + {:keep_state, updated_data, wrap_next_actions(next_actions)} + + # handle stop end else - {:error, :econnrefused} -> - {:error, {:connection_refused, host, port}} + :keep_state_and_data + end + end + + # connection logic =================================================== + def handle_event(:internal, :connect, :connecting, %State{} = data) do + transport = Keyword.get(data.opts, :transport) + # We cannot use the client_id to identify the connection because + # it could be the case that the server assign the client_id + {:ok, t_pid} = + Tortoise.TransmitterSupervisor.start_transmitter( + parent: self(), + transport: transport + ) + + data = %State{data | receiver: {t_pid, Process.monitor(t_pid)}} - {:error, :nxdomain} -> - {:error, {:nxdomain, host, port}} + # setup connection loop + + # TODO make backoff a user defined callback with a default + {timeout, data} = Map.get_and_update(data, :backoff, &Backoff.next/1) + next_actions = [{:state_timeout, timeout, :attempt_connection}] + + {:keep_state, data, next_actions} + end + + def handle_event( + :state_timeout, + :attempt_connection, + :connecting, + %State{ + connect: connect, + receiver: {receiver_pid, _mon_ref} + } = data + ) do + case Tortoise.Connection.Receiver.connect(receiver_pid) do + {:ok, {transport, socket} = connection} -> + # TODO: send this to the client handler allowing the user to + # specify a custom connect package + :ok = transport.send(socket, Package.encode(connect)) + + new_data = %State{ + data + | connect: %Connect{connect | clean_start: false}, + connection: connection + } - {:error, {:options, {:cacertfile, []}}} -> - {:error, :no_cacartfile_specified} + {:keep_state, new_data} - {:error, :closed} -> - {:error, :server_closed_connection} + {:error, {:stop, reason}} -> + {:stop, reason, data} + + {:error, {:retry, _reason}} -> + transition_actions = [{:next_event, :internal, :connect}] + {:keep_state, data, transition_actions} end end - defp init_connection(socket, %State{opts: opts, server: transport, connect: connect} = state) do - connection = {transport.type, socket} - :ok = start_connection_supervisor(opts) - :ok = Receiver.handle_socket(connect.client_id, connection) - :ok = Tortoise.Registry.put_meta(via_name(connect.client_id), connection) - :ok = Events.dispatch(connect.client_id, :connection, connection) + # disconnect protocol messages --------------------------------------- + def handle_event( + {:call, from}, + {:disconnect, %Package.Disconnect{} = disconnect}, + :connected, + %State{connection: {transport, socket}} = data + ) do + # TODO consider draining messages with QoS + :ok = transport.send(socket, Package.encode(disconnect)) + + {:stop_and_reply, :shutdown, [{:reply, from, :ok}], data} + end - # set clean session to false for future reconnect attempts - connect = %Connect{connect | clean_session: false} - {:ok, %State{state | connect: connect}} + def handle_event( + {:call, _from}, + {:disconnect, _reason}, + _, + %State{} + ) do + {:keep_state_and_data, [:postpone]} end - defp start_connection_supervisor(opts) do - case Connection.Supervisor.start_link(opts) do - {:ok, _pid} -> - :ok + # ping handling ------------------------------------------------------ + def handle_event({:call, {_, ref} = from}, :ping, :connected, %State{session: session} = data) do + next_actions = [{:reply, from, {:ok, {session.client_id, ref}}}] + + case data.ping do + {:idle, awaiting} -> + next_actions = [ + {:next_event, :internal, :trigger_keep_alive} + | next_actions + ] - {:error, {:already_started, _pid}} -> - :ok + {:keep_state, %State{data | ping: {:idle, [from | awaiting]}}, next_actions} + + {{:pinging, start_time}, awaiting} -> + {:keep_state, %State{data | ping: {{:pinging, start_time}, [from | awaiting]}}, + next_actions} end end - defp categorize_error({:nxdomain, _host, _port}) do - :connectivity + # not connected yet + def handle_event({:call, from}, :ping, _, %State{}) do + next_actions = [{:reply, from, {:error, :not_connected}}] + {:keep_state_and_data, next_actions} + end + + # keep alive --------------------------------------------------------- + def handle_event( + :internal, + :setup_keep_alive_timer, + :connected, + %State{info: %Info{keep_alive: keep_alive}} + ) do + next_actions = [{:state_timeout, keep_alive * 1000, :keep_alive}] + {:keep_state_and_data, next_actions} end - defp categorize_error({:connection_refused, _host, _port}) do - :connectivity + def handle_event(:internal, :setup_keep_alive_timer, _other_states, _data) do + :keep_state_and_data end - defp categorize_error(:server_closed_connection) do - :connectivity + def handle_event(:internal, :trigger_keep_alive, :connected, _data) do + # set the keep alive timeout to trigger instantly + next_actions = [{:state_timeout, 0, :keep_alive}] + {:keep_state_and_data, next_actions} end - defp categorize_error(_other) do - :other + def handle_event(:internal, :trigger_keep_alive, _other_states, _data) do + :keep_state_and_data end + + def handle_event( + :state_timeout, + :keep_alive, + :connected, + %State{connection: {transport, socket}, ping: {:idle, awaiting}} = data + ) do + start_time = System.monotonic_time() + + :ok = transport.send(socket, Package.encode(%Package.Pingreq{})) + + {:keep_state, %State{data | ping: {{:pinging, start_time}, awaiting}}} + end + + def handle_event( + :internal, + {:received, %Package.Pingresp{}}, + :connected, + %State{ping: {{:pinging, start_time}, awaiting}, session: session} = data + ) do + round_trip_time = + (System.monotonic_time() - start_time) + |> System.convert_time_unit(:native, :microsecond) + + # reply to the clients + Enum.each(awaiting, &send_reply(session.client_id, &1, Package.Pingreq, round_trip_time)) + + next_actions = [{:next_event, :internal, :setup_keep_alive_timer}] + + {:keep_state, %State{data | ping: {:idle, []}}, next_actions} + end + + # server initiated disconnect packages + def handle_event( + :internal, + {:received, %Package.Disconnect{} = disconnect}, + _current_state, + %State{handler: handler} = data + ) do + case Handler.execute_handle_disconnect(handler, {:server, disconnect}) do + {:ok, updated_handler, next_actions} -> + # we should close the network connection now (or assume that + # the server will shutdown the connection immediately); from + # here we could reconnect, or stop + {:keep_state, %State{data | handler: updated_handler}, wrap_next_actions(next_actions)} + + {:stop, reason, updated_handler} -> + {:stop, reason, %State{data | handler: updated_handler}} + end + end + + # unexpected package + def handle_event( + :internal, + {:received, package}, + _current_state, + %State{} = data + ) do + {:stop, {:protocol_violation, {:unexpected_package, package}}, data} + end + + def handle_event( + :info, + {:DOWN, receiver_ref, :process, receiver_pid, _reason}, + state, + %State{receiver: {receiver_pid, receiver_ref}} = data + ) + when state in [:connected, :connecting] do + next_actions = [{:next_event, :internal, :connect}] + updated_data = %State{data | receiver: nil} + {:next_state, :connecting, updated_data, next_actions} + end + + # wrapping the user specified next actions in gen_statem next actions; + # this is used in all the handle callback functions, so we inline it + defp wrap_next_actions(next_actions) do + for action <- next_actions do + {:next_event, :internal, {:user_action, action}} + end + end + + defp send_reply(client_id, {caller, ref}, topic, payload) + when is_pid(caller) and is_reference(ref) do + send(caller, {{Tortoise, client_id}, {topic, ref}, payload}) + end + + @compile {:inline, wrap_next_actions: 1, send_reply: 4} end diff --git a/lib/tortoise/connection/backoff.ex b/lib/tortoise/connection/backoff.ex index a7a4a155..6af2be1f 100644 --- a/lib/tortoise/connection/backoff.ex +++ b/lib/tortoise/connection/backoff.ex @@ -16,18 +16,16 @@ defmodule Tortoise.Connection.Backoff do end def next(%State{value: nil} = state) do - current = state.min_interval - {current, %State{state | value: current}} + {0, %State{state | value: state.min_interval}} end - def next(%State{max_interval: same, value: same} = state) do - current = state.min_interval - {current, %State{state | value: current}} + def next(%State{max_interval: value, value: value} = state) do + {value, %State{state | value: nil}} end def next(%State{value: value} = state) do - current = min(value * 2, state.max_interval) - {current, %State{state | value: current}} + next = min(value * 2, state.max_interval) + {value, %State{state | value: next}} end def reset(%State{} = state) do diff --git a/lib/tortoise/connection/controller.ex b/lib/tortoise/connection/controller.ex deleted file mode 100644 index 66c1c748..00000000 --- a/lib/tortoise/connection/controller.ex +++ /dev/null @@ -1,355 +0,0 @@ -defmodule Tortoise.Connection.Controller do - @moduledoc false - - require Logger - - alias Tortoise.{Package, Connection, Handler} - alias Tortoise.Connection.Inflight - - alias Tortoise.Package.{ - Connect, - Connack, - Disconnect, - Publish, - Puback, - Pubrec, - Pubrel, - Pubcomp, - Subscribe, - Suback, - Unsubscribe, - Unsuback, - Pingreq, - Pingresp - } - - use GenServer - - @enforce_keys [:client_id, :handler] - defstruct client_id: nil, - ping: :queue.new(), - status: :down, - awaiting: %{}, - handler: %Handler{module: Handler.Default, initial_args: []} - - alias __MODULE__, as: State - - # Client API - def start_link(opts) do - client_id = Keyword.fetch!(opts, :client_id) - handler = Handler.new(Keyword.fetch!(opts, :handler)) - - init_state = %State{ - client_id: client_id, - handler: handler - } - - GenServer.start_link(__MODULE__, init_state, name: via_name(client_id)) - end - - defp via_name(client_id) do - Tortoise.Registry.via_name(__MODULE__, client_id) - end - - def stop(client_id) do - GenServer.stop(via_name(client_id)) - end - - def info(client_id) do - GenServer.call(via_name(client_id), :info) - end - - @spec ping(Tortoise.client_id()) :: {:ok, reference()} - def ping(client_id) do - ref = make_ref() - :ok = GenServer.cast(via_name(client_id), {:ping, {self(), ref}}) - {:ok, ref} - end - - @spec ping_sync(Tortoise.client_id(), timeout()) :: {:ok, reference()} | {:error, :timeout} - def ping_sync(client_id, timeout \\ :infinity) do - {:ok, ref} = ping(client_id) - - receive do - {Tortoise, {:ping_response, ^ref, round_trip_time}} -> - {:ok, round_trip_time} - after - timeout -> - {:error, :timeout} - end - end - - @doc false - def handle_incoming(client_id, package) do - GenServer.cast(via_name(client_id), {:incoming, package}) - end - - @doc false - def handle_result(client_id, {{pid, ref}, Package.Publish, result}) do - send(pid, {{Tortoise, client_id}, ref, result}) - :ok - end - - def handle_result(client_id, {{pid, ref}, type, result}) do - send(pid, {{Tortoise, client_id}, ref, result}) - GenServer.cast(via_name(client_id), {:result, {type, result}}) - end - - @doc false - def handle_onward(client_id, %Package.Publish{} = publish) do - GenServer.cast(via_name(client_id), {:onward, publish}) - end - - # Server callbacks - @impl true - def init(%State{handler: handler} = opts) do - {:ok, _} = Tortoise.Events.register(opts.client_id, :status) - - case Handler.execute(handler, :init) do - {:ok, %Handler{} = updated_handler} -> - {:ok, %State{opts | handler: updated_handler}} - end - end - - @impl true - def terminate(reason, %State{handler: handler}) do - _ignored = Handler.execute(handler, {:terminate, reason}) - :ok - end - - @impl true - def handle_call(:info, _from, state) do - {:reply, state, state} - end - - @impl true - def handle_cast({:incoming, <>}, state) do - package - |> Package.decode() - |> handle_package(state) - end - - # allow for passing in already decoded packages into the controller, - # this allow us to test the controller without having to pass in - # binaries - def handle_cast({:incoming, %{:__META__ => _} = package}, state) do - handle_package(package, state) - end - - def handle_cast({:ping, caller}, state) do - with {:ok, {transport, socket}} <- Connection.connection(state.client_id) do - time = System.monotonic_time(:microsecond) - apply(transport, :send, [socket, Package.encode(%Package.Pingreq{})]) - ping = :queue.in({caller, time}, state.ping) - {:noreply, %State{state | ping: ping}} - else - {:error, :unknown_connection} -> - {:stop, :unknown_connection, state} - end - end - - def handle_cast( - {:result, {Package.Subscribe, subacks}}, - %State{handler: handler} = state - ) do - case Handler.execute(handler, {:subscribe, subacks}) do - {:ok, updated_handler} -> - {:noreply, %State{state | handler: updated_handler}} - end - end - - def handle_cast( - {:result, {Package.Unsubscribe, unsubacks}}, - %State{handler: handler} = state - ) do - case Handler.execute(handler, {:unsubscribe, unsubacks}) do - {:ok, updated_handler} -> - {:noreply, %State{state | handler: updated_handler}} - end - end - - # an incoming publish with QoS=2 will get parked in the inflight - # manager process, which will onward it to the controller, making - # sure we will only dispatch it once to the publish-handler. - def handle_cast( - {:onward, %Package.Publish{qos: 2, dup: false} = publish}, - %State{handler: handler} = state - ) do - case Handler.execute(handler, {:publish, publish}) do - {:ok, updated_handler} -> - {:noreply, %State{state | handler: updated_handler}} - end - end - - @impl true - def handle_info({:next_action, {:subscribe, topic, opts} = action}, state) do - {qos, opts} = Keyword.pop_first(opts, :qos, 0) - - case Tortoise.Connection.subscribe(state.client_id, [{topic, qos}], opts) do - {:ok, ref} -> - updated_awaiting = Map.put_new(state.awaiting, ref, action) - {:noreply, %State{state | awaiting: updated_awaiting}} - end - end - - def handle_info({:next_action, {:unsubscribe, topic} = action}, state) do - case Tortoise.Connection.unsubscribe(state.client_id, topic) do - {:ok, ref} -> - updated_awaiting = Map.put_new(state.awaiting, ref, action) - {:noreply, %State{state | awaiting: updated_awaiting}} - end - end - - # connection changes - def handle_info( - {{Tortoise, client_id}, :status, same}, - %State{client_id: client_id, status: same} = state - ) do - {:noreply, state} - end - - def handle_info( - {{Tortoise, client_id}, :status, new_status}, - %State{client_id: client_id, handler: handler} = state - ) do - case Handler.execute(handler, {:connection, new_status}) do - {:ok, updated_handler} -> - {:noreply, %State{state | handler: updated_handler, status: new_status}} - end - end - - def handle_info({{Tortoise, client_id}, ref, result}, %{client_id: client_id} = state) do - case {result, Map.pop(state.awaiting, ref)} do - {_, {nil, _}} -> - Logger.warn("Unexpected async result") - {:noreply, state} - - {:ok, {_action, updated_awaiting}} -> - {:noreply, %State{state | awaiting: updated_awaiting}} - end - end - - # QoS LEVEL 0 ======================================================== - # commands ----------------------------------------------------------- - defp handle_package( - %Publish{qos: 0, dup: false} = publish, - %State{handler: handler} = state - ) do - case Handler.execute(handler, {:publish, publish}) do - {:ok, updated_handler} -> - {:noreply, %State{state | handler: updated_handler}} - - # handle stop - end - end - - # QoS LEVEL 1 ======================================================== - # commands ----------------------------------------------------------- - defp handle_package( - %Publish{qos: 1} = publish, - %State{handler: handler} = state - ) do - :ok = Inflight.track(state.client_id, {:incoming, publish}) - - case Handler.execute(handler, {:publish, publish}) do - {:ok, updated_handler} -> - {:noreply, %State{state | handler: updated_handler}} - end - end - - # response ----------------------------------------------------------- - defp handle_package(%Puback{} = puback, state) do - :ok = Inflight.update(state.client_id, {:received, puback}) - {:noreply, state} - end - - # QoS LEVEL 2 ======================================================== - # commands ----------------------------------------------------------- - defp handle_package(%Publish{qos: 2} = publish, %State{} = state) do - :ok = Inflight.track(state.client_id, {:incoming, publish}) - {:noreply, state} - end - - defp handle_package(%Pubrel{} = pubrel, state) do - :ok = Inflight.update(state.client_id, {:received, pubrel}) - {:noreply, state} - end - - # response ----------------------------------------------------------- - defp handle_package(%Pubrec{} = pubrec, state) do - :ok = Inflight.update(state.client_id, {:received, pubrec}) - {:noreply, state} - end - - defp handle_package(%Pubcomp{} = pubcomp, state) do - :ok = Inflight.update(state.client_id, {:received, pubcomp}) - {:noreply, state} - end - - # SUBSCRIBING ======================================================== - # command ------------------------------------------------------------ - defp handle_package(%Subscribe{} = subscribe, state) do - # not a server! (yet) - {:stop, {:protocol_violation, {:unexpected_package_from_remote, subscribe}}, state} - end - - # response ----------------------------------------------------------- - defp handle_package(%Suback{} = suback, state) do - :ok = Inflight.update(state.client_id, {:received, suback}) - {:noreply, state} - end - - # UNSUBSCRIBING ====================================================== - # command ------------------------------------------------------------ - defp handle_package(%Unsubscribe{} = unsubscribe, state) do - # not a server - {:stop, {:protocol_violation, {:unexpected_package_from_remote, unsubscribe}}, state} - end - - # response ----------------------------------------------------------- - defp handle_package(%Unsuback{} = unsuback, state) do - :ok = Inflight.update(state.client_id, {:received, unsuback}) - {:noreply, state} - end - - # PING MESSAGES ====================================================== - # command ------------------------------------------------------------ - defp handle_package(%Pingresp{}, %State{ping: ping} = state) - when is_nil(ping) or ping == {[], []} do - {:noreply, state} - end - - defp handle_package(%Pingresp{}, %State{ping: ping} = state) do - {{:value, {{caller, ref}, start_time}}, ping} = :queue.out(ping) - round_trip_time = System.monotonic_time(:microsecond) - start_time - send(caller, {Tortoise, {:ping_response, ref, round_trip_time}}) - {:noreply, %State{state | ping: ping}} - end - - # response ----------------------------------------------------------- - defp handle_package(%Pingreq{} = pingreq, state) do - # not a server! - {:stop, {:protocol_violation, {:unexpected_package_from_remote, pingreq}}, state} - end - - # CONNECTING ========================================================= - # command ------------------------------------------------------------ - defp handle_package(%Connect{} = connect, state) do - # not a server! - {:stop, {:protocol_violation, {:unexpected_package_from_remote, connect}}, state} - end - - # response ----------------------------------------------------------- - defp handle_package(%Connack{} = connack, state) do - # receiving a connack at this point would be a protocol violation - {:stop, {:protocol_violation, {:unexpected_package_from_remote, connack}}, state} - end - - # DISCONNECTING ====================================================== - # command ------------------------------------------------------------ - defp handle_package(%Disconnect{} = disconnect, state) do - # This should be allowed when we implement MQTT 5. Remember there - # is a test that assert this as a protocol violation! - {:stop, {:protocol_violation, {:unexpected_package_from_remote, disconnect}}, state} - end -end diff --git a/lib/tortoise/connection/inflight.ex b/lib/tortoise/connection/inflight.ex deleted file mode 100644 index eabc5a25..00000000 --- a/lib/tortoise/connection/inflight.ex +++ /dev/null @@ -1,378 +0,0 @@ -defmodule Tortoise.Connection.Inflight do - @moduledoc false - - alias Tortoise.{Package, Connection} - alias Tortoise.Connection.Controller - alias Tortoise.Connection.Inflight.Track - - use GenStateMachine - - @enforce_keys [:client_id] - defstruct client_id: nil, pending: %{}, order: [] - - alias __MODULE__, as: State - - # Client API - def start_link(opts) do - client_id = Keyword.fetch!(opts, :client_id) - GenStateMachine.start_link(__MODULE__, opts, name: via_name(client_id)) - end - - defp via_name(client_id) do - Tortoise.Registry.via_name(__MODULE__, client_id) - end - - def stop(client_id) do - GenStateMachine.stop(via_name(client_id)) - end - - @doc false - def drain(client_id) do - GenStateMachine.call(via_name(client_id), :drain) - end - - @doc false - def track(client_id, {:incoming, %Package.Publish{qos: qos} = publish}) - when qos in 1..2 do - :ok = GenStateMachine.cast(via_name(client_id), {:incoming, publish}) - end - - def track(client_id, {:outgoing, package}) do - caller = {_, ref} = {self(), make_ref()} - - case package do - %Package.Publish{qos: qos} when qos in 1..2 -> - :ok = GenStateMachine.cast(via_name(client_id), {:outgoing, caller, package}) - {:ok, ref} - - %Package.Subscribe{} -> - :ok = GenStateMachine.cast(via_name(client_id), {:outgoing, caller, package}) - {:ok, ref} - - %Package.Unsubscribe{} -> - :ok = GenStateMachine.cast(via_name(client_id), {:outgoing, caller, package}) - {:ok, ref} - end - end - - @doc false - def track_sync(client_id, {:outgoing, _} = command, timeout \\ :infinity) do - {:ok, ref} = track(client_id, command) - - receive do - {{Tortoise, ^client_id}, ^ref, result} -> - result - after - timeout -> {:error, :timeout} - end - end - - @doc false - def update(client_id, {_, %{__struct__: _, identifier: _identifier}} = event) do - :ok = GenStateMachine.cast(via_name(client_id), {:update, event}) - end - - @doc false - def reset(client_id) do - :ok = GenStateMachine.cast(via_name(client_id), :reset) - end - - # Server callbacks - @impl true - def init(opts) do - client_id = Keyword.fetch!(opts, :client_id) - initial_data = %State{client_id: client_id} - - next_actions = [ - {:next_event, :internal, :post_init} - ] - - {:ok, :disconnected, initial_data, next_actions} - end - - @impl true - def handle_event(:internal, :post_init, :disconnected, data) do - case Connection.connection(data.client_id, active: true) do - {:ok, {_transport, _socket} = connection} -> - {:ok, _} = Tortoise.Events.register(data.client_id, :status) - {:next_state, {:connected, connection}, data} - - {:error, :timeout} -> - {:stop, :connection_timeout} - - {:error, :unknown_connection} -> - {:stop, :unknown_connection} - end - end - - # When we receive a new connection we will use that for our future - # transmissions. - def handle_event( - :info, - {{Tortoise, client_id}, :connection, connection}, - _current_state, - %State{client_id: client_id, pending: pending} = data - ) do - next_actions = - for identifier <- Enum.reverse(data.order) do - case Map.get(pending, identifier, :unknown) do - %Track{pending: [[{:dispatch, %Package.Publish{} = publish} | action] | pending]} = - track -> - publish = %Package.Publish{publish | dup: true} - track = %Track{track | pending: [[{:dispatch, publish} | action] | pending]} - {:next_event, :internal, {:execute, track}} - - %Track{} = track -> - {:next_event, :internal, {:execute, track}} - end - end - - {:next_state, {:connected, connection}, data, next_actions} - end - - # Connection status events; when we go offline we should transition - # into the disconnected state. Everything else will get ignored. - def handle_event( - :info, - {{Tortoise, client_id}, :status, :down}, - _current_state, - %State{client_id: client_id} = data - ) do - {:next_state, :disconnected, data} - end - - def handle_event(:info, {{Tortoise, _}, :status, _}, _, %State{}) do - :keep_state_and_data - end - - # Create. Notice: we will only receive publish packages from the - # remote; everything else is something we initiate - def handle_event( - :cast, - {:incoming, %Package.Publish{dup: false} = package}, - _state, - %State{pending: pending} = data - ) do - track = Track.create(:positive, package) - - data = %State{ - data - | pending: Map.put_new(pending, track.identifier, track), - order: [track.identifier | data.order] - } - - next_actions = [ - {:next_event, :internal, {:onward_publish, package}}, - {:next_event, :internal, {:execute, track}} - ] - - {:keep_state, data, next_actions} - end - - # possible duplicate - def handle_event(:cast, {:incoming, _}, :draining, %State{}) do - :keep_state_and_data - end - - def handle_event( - :cast, - {:incoming, %Package.Publish{identifier: identifier, dup: true} = publish}, - _state, - %State{pending: pending} = data - ) do - case Map.get(pending, identifier) do - nil -> - next_actions = [ - {:next_event, :cast, {:incoming, %Package.Publish{publish | dup: false}}} - ] - - {:keep_state_and_data, next_actions} - - %Track{polarity: :positive, status: [{:received, %{__struct__: Package.Publish}}]} -> - :keep_state_and_data - - _otherwise -> - {:stop, :state_out_of_sync, data} - end - end - - def handle_event(:cast, {:outgoing, {pid, ref}, _}, :draining, data) do - send(pid, {{Tortoise, data.client_id}, ref, {:error, :terminating}}) - :keep_state_and_data - end - - def handle_event(:cast, {:outgoing, caller, package}, _state, data) do - {:ok, package} = assign_identifier(package, data.pending) - track = Track.create({:negative, caller}, package) - - next_actions = [ - {:next_event, :internal, {:execute, track}} - ] - - data = %State{ - data - | pending: Map.put_new(data.pending, track.identifier, track), - order: [track.identifier | data.order] - } - - {:keep_state, data, next_actions} - end - - # update - def handle_event(:cast, {:update, _}, :draining, _data) do - :keep_state_and_data - end - - def handle_event( - :cast, - {:update, {_, %{identifier: identifier}} = update}, - _state, - %State{pending: pending} = data - ) do - with {:ok, track} <- Map.fetch(pending, identifier), - {:ok, track} <- Track.resolve(track, update) do - next_actions = [ - {:next_event, :internal, {:execute, track}} - ] - - data = %State{ - data - | pending: Map.put(pending, identifier, track), - order: [identifier | data.order -- [identifier]] - } - - {:keep_state, data, next_actions} - else - :error -> - {:stop, {:protocol_violation, :unknown_identifier}, data} - - {:error, reason} -> - {:stop, reason, data} - end - end - - def handle_event(:cast, :reset, _, %State{pending: pending} = data) do - # cancel all currently outgoing messages - for {_, %Track{polarity: :negative, caller: {pid, ref}}} <- pending do - send(pid, {{Tortoise, data.client_id}, ref, {:error, :canceled}}) - end - - {:keep_state, %State{data | pending: %{}, order: []}} - end - - # We trap the incoming QoS 2 packages in the inflight manager so we - # can make sure we will not onward them to the connection handler - # more than once. - def handle_event( - :internal, - {:onward_publish, %Package.Publish{qos: 2} = publish}, - _, - %State{} = data - ) do - :ok = Controller.handle_onward(data.client_id, publish) - :keep_state_and_data - end - - # The other package types should not get onwarded to the controller - # handler - def handle_event(:internal, {:onward_publish, _}, _, %State{}) do - :keep_state_and_data - end - - def handle_event(:internal, {:execute, _}, :draining, _) do - :keep_state_and_data - end - - def handle_event( - :internal, - {:execute, %Track{pending: [[{:dispatch, package}, _] | _]} = track}, - {:connected, {transport, socket}}, - %State{} = data - ) do - case apply(transport, :send, [socket, Package.encode(package)]) do - :ok -> - {:keep_state, handle_next(track, data)} - end - end - - def handle_event( - :internal, - {:execute, %Track{pending: [[{:dispatch, _}, _] | _]}}, - :disconnected, - %State{} - ) do - # the dispatch will get re-queued when we regain the connection - :keep_state_and_data - end - - def handle_event( - :internal, - {:execute, %Track{pending: [[{:respond, caller}, _] | _]} = track}, - _state, - %State{client_id: client_id} = data - ) do - case Track.result(track) do - {:ok, result} -> - :ok = Controller.handle_result(client_id, {caller, track.type, result}) - {:keep_state, handle_next(track, data)} - end - end - - def handle_event({:call, from}, :drain, {:connected, {transport, socket}}, %State{} = data) do - for {_, %Track{polarity: :negative, caller: {pid, ref}}} <- data.pending do - send(pid, {{Tortoise, data.client_id}, ref, {:error, :canceled}}) - end - - data = %State{data | pending: %{}, order: []} - disconnect = %Package.Disconnect{} - - case apply(transport, :send, [socket, Package.encode(disconnect)]) do - :ok -> - :ok = transport.close(socket) - reply = {:reply, from, :ok} - {:next_state, :draining, data, reply} - end - end - - # helpers ------------------------------------------------------------ - defp handle_next( - %Track{pending: [[_, :cleanup]], identifier: identifier}, - %State{pending: pending} = state - ) do - order = state.order -- [identifier] - %State{state | pending: Map.delete(pending, identifier), order: order} - end - - defp handle_next(_track, %State{} = state) do - state - end - - # Assign a random identifier to the tracked package; this will make - # sure we pick a random number that is not in use - defp assign_identifier(%{identifier: nil} = package, pending) do - case :crypto.strong_rand_bytes(2) do - <<0, 0>> -> - # an identifier cannot be zero - assign_identifier(package, pending) - - <> -> - unless Map.has_key?(pending, identifier) do - {:ok, %{package | identifier: identifier}} - else - assign_identifier(package, pending) - end - end - end - - # ...as such we should let the in-flight process assign identifiers, - # but the possibility to pass one in has been kept so we can make - # deterministic unit tests - defp assign_identifier(%{identifier: identifier} = package, pending) - when identifier in 0x0001..0xFFFF do - unless Map.has_key?(pending, identifier) do - {:ok, package} - else - {:error, {:identifier_already_in_use, identifier}} - end - end -end diff --git a/lib/tortoise/connection/inflight/track.ex b/lib/tortoise/connection/inflight/track.ex deleted file mode 100644 index 878b7409..00000000 --- a/lib/tortoise/connection/inflight/track.ex +++ /dev/null @@ -1,248 +0,0 @@ -defmodule Tortoise.Connection.Inflight.Track do - @moduledoc false - - # A data structure implementing state machines tracking the state of a - # message in flight. - - # Messages can have two polarities, positive and negative, describing - # what direction they are going. A positive polarity is messages - # coming from the server to the client (us), a negative polarity is - # messages send from the client (us) to the server. - - # For now we care about tracking the state of a handful of message - # kinds: the publish control packages with a quality of service above - # 0 and subscribe and unsubscribe control packages. We do not track - # the in-flight state of a QoS 0 control packet because there is no - # state to track. - - # For negative polarity we need to track the caller, which is the - # process that instantiated the publish control package. This process - # will wait for a message to get passed to it when the ownership of - # the control package has been transferred to the server. Messages - # with a positive polarity will get passed to the callback module - # attached to the Controller module, so in that case there will be no - # caller. - - @type package :: Package.Publish | Package.Subscribe | Package.Unsubscribe - - @type caller :: {pid(), reference()} | nil - @type polarity :: :positive | {:negative, caller()} - @type next_action :: {:dispatch | :expect, Tortoise.Encodable.t()} - @type status_update :: {:received | :dispatched, Tortoise.Encodable.t()} - - @opaque t :: %__MODULE__{ - polarity: :positive | :negative, - type: package, - caller: {pid(), reference()} | nil, - identifier: Tortoise.package_identifier(), - status: [status_update()], - pending: [next_action()] - } - @enforce_keys [:type, :identifier, :polarity, :pending] - defstruct type: nil, - polarity: nil, - caller: nil, - identifier: nil, - status: [], - pending: [] - - alias __MODULE__, as: State - alias Tortoise.Package - - def next(%State{pending: [[next_action, resolution] | _]}) do - {next_action, resolution} - end - - def resolve(%State{pending: [[action, :cleanup]]} = state, :cleanup) do - {:ok, %State{state | pending: [], status: [action | state.status]}} - end - - def resolve( - %State{pending: [[action, {:received, %{__struct__: t, identifier: id}}] | rest]} = state, - {:received, %{__struct__: t, identifier: id}} = expected - ) do - {:ok, %State{state | pending: rest, status: [expected, action | state.status]}} - end - - # the value has previously been received; here we should stay where - # we are at and retry the transmission - def resolve( - %State{status: [{same, %{__struct__: t, identifier: id}} | _]} = state, - {same, %{__struct__: t, identifier: id}} - ) do - {:ok, state} - end - - def resolve(%State{pending: []} = state, :cleanup) do - {:ok, state} - end - - def resolve(%State{}, {:received, package}) do - {:error, {:protocol_violation, {:unexpected_package_from_remote, package}}} - end - - @type trackable :: Tortoise.Encodable - - @doc """ - Set up a data structure that will track the status of a control - packet - """ - # @todo, enable this when I've figured out what is wrong with this spec - # @spec create(polarity :: polarity(), package :: trackable()) :: __MODULE__.t() - def create(:positive, %Package.Publish{qos: 1, identifier: id} = publish) do - %State{ - type: Package.Publish, - polarity: :positive, - identifier: id, - status: [{:received, publish}], - pending: [ - [ - {:dispatch, %Package.Puback{identifier: id}}, - :cleanup - ] - ] - } - end - - def create({:negative, {pid, ref}}, %Package.Publish{qos: 1, identifier: id} = publish) - when is_pid(pid) and is_reference(ref) do - %State{ - type: Package.Publish, - polarity: :negative, - caller: {pid, ref}, - identifier: id, - pending: [ - [ - {:dispatch, publish}, - {:received, %Package.Puback{identifier: id}} - ], - [ - {:respond, {pid, ref}}, - :cleanup - ] - ] - } - end - - def create(:positive, %Package.Publish{identifier: id, qos: 2} = publish) do - %State{ - type: Package.Publish, - polarity: :positive, - identifier: id, - status: [{:received, publish}], - pending: [ - [ - {:dispatch, %Package.Pubrec{identifier: id}}, - {:received, %Package.Pubrel{identifier: id}} - ], - [ - {:dispatch, %Package.Pubcomp{identifier: id}}, - :cleanup - ] - ] - } - end - - def create({:negative, {pid, ref}}, %Package.Publish{identifier: id, qos: 2} = publish) - when is_pid(pid) and is_reference(ref) do - %State{ - type: Package.Publish, - polarity: :negative, - caller: {pid, ref}, - identifier: id, - pending: [ - [ - {:dispatch, publish}, - {:received, %Package.Pubrec{identifier: id}} - ], - [ - {:dispatch, %Package.Pubrel{identifier: id}}, - {:received, %Package.Pubcomp{identifier: id}} - ], - [ - {:respond, {pid, ref}}, - :cleanup - ] - ] - } - end - - # subscription - def create({:negative, {pid, ref}}, %Package.Subscribe{identifier: id} = subscribe) - when is_pid(pid) and is_reference(ref) do - %State{ - type: Package.Subscribe, - polarity: :negative, - caller: {pid, ref}, - identifier: id, - pending: [ - [ - {:dispatch, subscribe}, - {:received, %Package.Suback{identifier: id}} - ], - [ - {:respond, {pid, ref}}, - :cleanup - ] - ] - } - end - - def create({:negative, {pid, ref}}, %Package.Unsubscribe{identifier: id} = unsubscribe) - when is_pid(pid) and is_reference(ref) do - %State{ - type: Package.Unsubscribe, - polarity: :negative, - caller: {pid, ref}, - identifier: id, - pending: [ - [ - {:dispatch, unsubscribe}, - {:received, %Package.Unsuback{identifier: id}} - ], - [ - {:respond, {pid, ref}}, - :cleanup - ] - ] - } - end - - # calculate result - def result(%State{type: Package.Publish}) do - {:ok, :ok} - end - - def result(%State{ - type: Package.Unsubscribe, - status: [ - {:received, _}, - {:dispatch, %Package.Unsubscribe{topics: topics}} | _other - ] - }) do - {:ok, topics} - end - - def result(%State{ - type: Package.Subscribe, - status: [ - {:received, %Package.Suback{acks: acks}}, - {:dispatch, %Package.Subscribe{topics: topics}} | _other - ] - }) do - result = - List.zip([topics, acks]) - |> Enum.reduce(%{error: [], warn: [], ok: []}, fn - {{topic, level}, {:ok, level}}, %{ok: oks} = acc -> - %{acc | ok: oks ++ [{topic, level}]} - - {{topic, requested}, {:ok, actual}}, %{warn: warns} = acc -> - %{acc | warn: warns ++ [{topic, [requested: requested, accepted: actual]}]} - - {{topic, level}, {:error, :access_denied}}, %{error: errors} = acc -> - %{acc | error: errors ++ [{:access_denied, {topic, level}}]} - end) - - {:ok, result} - end -end diff --git a/lib/tortoise/connection/info.ex b/lib/tortoise/connection/info.ex new file mode 100644 index 00000000..3f638d6e --- /dev/null +++ b/lib/tortoise/connection/info.ex @@ -0,0 +1,42 @@ +defmodule Tortoise.Connection.Info do + @enforce_keys [:keep_alive] + defstruct client_id: nil, + subscriptions: %{}, + keep_alive: nil, + receiver_pid: nil, + capabilities: nil + + alias __MODULE__ + alias Tortoise.Package.{Connect, Connack} + + def merge( + %Connect{keep_alive: keep_alive} = connect, + %Connack{reason: :success} = connack + ) do + # if no server_keep_alive is set we should use the one set by the client + keep_alive = Keyword.get(connack.properties, :server_keep_alive, keep_alive) + + struct!(Info, + keep_alive: keep_alive, + client_id: get_client_id(connect, connack), + capabilities: struct!(Info.Capabilities, connack.properties) + ) + end + + # If the client does not specify a client id it should look for a + # server assigned client identifier in the connack properties. It + # would be a error if this is not specified + defp get_client_id(%Connect{client_id: nil}, %Connack{properties: properties}) do + case Keyword.get(properties, :assigned_client_identifier) do + nil -> + raise "No client id specified" + + client_id when is_binary(client_id) -> + client_id + end + end + + defp get_client_id(%Connect{client_id: client_id}, %Connack{}) do + client_id + end +end diff --git a/lib/tortoise/connection/info/capabilities.ex b/lib/tortoise/connection/info/capabilities.ex new file mode 100644 index 00000000..fb8a417a --- /dev/null +++ b/lib/tortoise/connection/info/capabilities.ex @@ -0,0 +1,127 @@ +defmodule Tortoise.Connection.Info.Capabilities do + @moduledoc false + + alias Tortoise.Package.Subscribe + + defstruct session_expiry_interval: 0, + receive_maximum: 0xFFFF, + maximum_qos: 2, + retain_available: true, + maximum_packet_size: 268_435_455, + assigned_client_identifier: nil, + topic_alias_maximum: 0, + wildcard_subscription_available: true, + subscription_identifiers_available: true, + shared_subscription_available: true, + server_keep_alive: nil + + def validate(%__MODULE__{} = config, package) do + config + |> Map.from_struct() + |> Map.to_list() + |> do_validate(package, []) + + # todo, make tests that setup connections with each of them + # disabled and attempt to subscribe with that feature + end + + defp do_validate([], _, []), do: :valid + defp do_validate([], _, reasons), do: {:invalid, reasons} + + # assigned client identifier (ignored) + defp do_validate([{:assigned_client_identifier, _ignore} | rest], package, acc) do + do_validate(rest, package, acc) + end + + # wildcard subscriptions --------------------------------------------- + defp do_validate( + [{:wildcard_subscription_available, false} | rest], + %Subscribe{topics: topics} = package, + acc + ) do + issues = + Enum.reduce(topics, [], fn {topic, _opts}, acc -> + topic_list = String.split(topic, "/") + + cond do + Enum.member?(topic_list, "+") -> + [{:wildcard_subscription_not_available, topic} | acc] + + Enum.member?(topic_list, "#") -> + # multi-level wildcards are only allowed on the last + # position, but we test for each of the positions because + # we would have to iterate all the elements if we did a + # `List.last/1` anyways + [{:wildcard_subscription_not_available, topic} | acc] + + true -> + acc + end + end) + + do_validate(rest, package, issues ++ acc) + end + + defp do_validate([{:wildcard_subscription_available, _ignored} | rest], package, acc) do + # This is only relevant for Subscribe packages + do_validate(rest, package, acc) + end + + # shared subscriptions ----------------------------------------------- + defp do_validate( + [{:shared_subscription_available, false} | rest], + %Subscribe{topics: topics} = package, + acc + ) do + issues = + for {topic, _opts} <- topics, match?("$share/" <> _, topic) do + {:shared_subscription_not_available, topic} + end + + do_validate(rest, package, issues ++ acc) + end + + defp do_validate( + [{:shared_subscription_available, true} | rest], + %Subscribe{topics: _topics} = package, + acc + ) do + # todo! + + # The ShareName MUST NOT contain the characters "/", "+" or "#", + # but MUST be followed by a "/" character. This "/" character MUST + # be followed by a Topic Filter [MQTT-4.8.2-2] as described in + # section 4.7. + + do_validate(rest, package, acc) + end + + defp do_validate([{:shared_subscription_available, _ignored} | rest], package, acc) do + # This is only relevant for Subscribe packages + do_validate(rest, package, acc) + end + + # subscription identifiers ------------------------------------------- + defp do_validate( + [{:subscription_identifiers_available, false} | rest], + %Subscribe{properties: properties} = package, + acc + ) do + if Enum.any?(properties, &match?({:subscription_identifier, _}, &1)) do + do_validate(rest, package, [:subscription_identifier_not_available | acc]) + else + do_validate(rest, package, acc) + end + end + + defp do_validate([{:subscription_identifiers_available, _ignored} | rest], package, acc) do + # This is only relevant for Subscribe packages + do_validate(rest, package, acc) + end + + # catch all; if an option is enabled, or not accounted for, we just + # assume it is okay at this point + defp do_validate([{_option, _value} | rest], subscribe, acc) do + do_validate(rest, subscribe, acc) + end +end diff --git a/lib/tortoise/connection/receiver.ex b/lib/tortoise/connection/receiver.ex index ecdd298f..a413e128 100644 --- a/lib/tortoise/connection/receiver.ex +++ b/lib/tortoise/connection/receiver.ex @@ -3,22 +3,23 @@ defmodule Tortoise.Connection.Receiver do use GenStateMachine - alias Tortoise.Connection.Controller - alias Tortoise.Events + alias Tortoise.Transport + + defstruct transport: nil, + socket: nil, + buffer: <<>>, + parent: nil, + parent_mon: nil - defstruct client_id: nil, transport: nil, socket: nil, buffer: <<>> alias __MODULE__, as: State def start_link(opts) do - client_id = Keyword.fetch!(opts, :client_id) - - data = %State{client_id: client_id} - - GenStateMachine.start_link(__MODULE__, data, name: via_name(client_id)) - end + data = %State{ + transport: Keyword.fetch!(opts, :transport), + parent: Keyword.fetch!(opts, :parent) + } - defp via_name(client_id) do - Tortoise.Registry.via_name(__MODULE__, client_id) + GenStateMachine.start_link(__MODULE__, data) end def child_spec(opts) do @@ -26,33 +27,32 @@ defmodule Tortoise.Connection.Receiver do id: __MODULE__, start: {__MODULE__, :start_link, [opts]}, type: :worker, - restart: :permanent, + # We will let the connection process monitor and start a new + # receiver process if the current one should crash + restart: :temporary, shutdown: 500 } end - def handle_socket(client_id, {transport, socket}) do - {:ok, pid} = GenStateMachine.call(via_name(client_id), {:handle_socket, transport, socket}) - - case transport.controlling_process(socket, pid) do - :ok -> - :ok - - {:error, reason} when reason in [:closed, :einval] -> - # todo, this is an edge case, figure out what to do here - :ok - end + def connect(pid) do + GenStateMachine.call(pid, :connect) end @impl true def init(%State{} = data) do - {:ok, :disconnected, data} + parent_mon = Process.monitor(data.parent) + {:ok, :disconnected, %State{data | parent_mon: parent_mon}} + end + + @impl true + def terminate(_reason, _state, _data) do + :ok end @impl true # receiving data on the network connection def handle_event(:info, {transport, socket, tcp_data}, _, %{socket: socket} = data) - when transport in [:tcp, :ssl] do + when transport in [:tcp, :ssl, ScriptedTransport] do next_actions = [ {:next_event, :internal, :activate_socket}, {:next_event, :internal, :consume_buffer} @@ -62,27 +62,31 @@ defmodule Tortoise.Connection.Receiver do {:keep_state, new_data, next_actions} end - # Dropped connection: tell the connection process that it should - # attempt to get a new network socket; unfortunately we cannot just - # monitor the socket port in the connection process as a transport - # method such as the SSL based one will pass an opaque data - # structure around instead of a port that can be monitored. - def handle_event(:info, {transport, socket}, _state, %{socket: socket} = data) - when transport in [:tcp_closed, :ssl_closed] do - # should we empty the buffer? - - # communicate to the world that we have dropped the connection - :ok = Events.dispatch(data.client_id, :status, :down) - {:next_state, :disconnected, %{data | socket: nil}} + def handle_event( + :info, + {:DOWN, ref, :process, pid, reason}, + _, + %State{parent: pid, parent_mon: ref} = data + ) do + # our parent process is shutting down + case reason do + :shutdown -> + {:stop, :normal, data} + end end - # activate network socket for incoming traffic - def handle_event(:internal, :activate_socket, _state_name, %State{transport: nil}) do - {:stop, :no_transport} + def handle_event(:info, unknown_info, _, data) do + {:stop, {:unknown_info, unknown_info}, data} end - def handle_event(:internal, :activate_socket, _state_name, data) do - case data.transport.setopts(data.socket, active: :once) do + # activate network socket for incoming traffic + def handle_event( + :internal, + :activate_socket, + _state_name, + %State{transport: %Transport{type: transport}} = data + ) do + case transport.setopts(data.socket, active: :once) do :ok -> :keep_state_and_data @@ -93,7 +97,7 @@ defmodule Tortoise.Connection.Receiver do end # consume buffer - def handle_event(:internal, :consume_buffer, _state_name, %{buffer: <<>>}) do + def handle_event(:internal, :consume_buffer, _current_name, %{buffer: <<>>}) do :keep_state_and_data end @@ -146,23 +150,50 @@ defmodule Tortoise.Connection.Receiver do end def handle_event(:internal, {:emit, package}, _, data) do - :ok = Controller.handle_incoming(data.client_id, package) + send(data.parent, {:incoming, package}) :keep_state_and_data end - def handle_event({:call, from}, {:handle_socket, transport, socket}, :disconnected, data) do - new_state = {:connected, :receiving_fixed_header} + # connect + def handle_event( + {:call, from}, + :connect, + :disconnected, + %State{ + transport: %Transport{type: transport, host: host, port: port, opts: opts} + } = data + ) do + case transport.connect(host, port, opts, 10000) do + {:ok, socket} -> + new_state = {:connected, :receiving_fixed_header} + + next_actions = [ + {:reply, from, {:ok, {transport, socket}}}, + {:next_event, :internal, :activate_socket}, + {:next_event, :internal, :consume_buffer} + ] + + # better make sure the buffer state is empty + new_data = %State{data | socket: socket, buffer: <<>>} + {:next_state, new_state, new_data, next_actions} + + {:error, reason} -> + next_actions = [{:reply, from, {:error, connection_error(reason)}}] + {:next_state, :disconnected, data, next_actions} + end + end - next_actions = [ - {:reply, from, {:ok, self()}}, - {:next_event, :internal, :activate_socket}, - {:next_event, :internal, :consume_buffer} - ] + defp connection_error(reason) do + case reason do + {:options, {:cacertfile, []}} -> + {:stop, :no_cacartfile_specified} - # better reset the buffer - new_data = %State{data | transport: transport, socket: socket, buffer: <<>>} + :nxdomain -> + {:retry, :nxdomain} - {:next_state, new_state, new_data, next_actions} + :econnrefused -> + {:retry, :econnrefused} + end end defp parse_fixed_header(<<_::8, 0::1, length::7, _::binary>>) do diff --git a/lib/tortoise/connection/supervisor.ex b/lib/tortoise/connection/supervisor.ex deleted file mode 100644 index d52cdd23..00000000 --- a/lib/tortoise/connection/supervisor.ex +++ /dev/null @@ -1,27 +0,0 @@ -defmodule Tortoise.Connection.Supervisor do - @moduledoc false - - use Supervisor - - alias Tortoise.Connection.{Receiver, Controller, Inflight} - - def start_link(opts) do - client_id = Keyword.fetch!(opts, :client_id) - Supervisor.start_link(__MODULE__, opts, name: via_name(client_id)) - end - - defp via_name(client_id) do - Tortoise.Registry.via_name(__MODULE__, client_id) - end - - @impl true - def init(opts) do - children = [ - {Inflight, Keyword.take(opts, [:client_id])}, - {Receiver, Keyword.take(opts, [:client_id])}, - {Controller, Keyword.take(opts, [:client_id, :handler])} - ] - - Supervisor.init(children, strategy: :rest_for_one) - end -end diff --git a/lib/tortoise/decodable.ex b/lib/tortoise/decodable.ex index aa99a3e9..a754762e 100644 --- a/lib/tortoise/decodable.ex +++ b/lib/tortoise/decodable.ex @@ -1,7 +1,7 @@ defprotocol Tortoise.Decodable do @moduledoc false - def decode(data) + def decode(data, opts \\ []) end defimpl Tortoise.Decodable, for: BitString do @@ -19,29 +19,31 @@ defimpl Tortoise.Decodable, for: BitString do Unsuback, Pingreq, Pingresp, - Disconnect + Disconnect, + Auth } - def decode(<<1::4, _::4, _::binary>> = data), do: Connect.decode(data) - def decode(<<2::4, _::4, _::binary>> = data), do: Connack.decode(data) - def decode(<<3::4, _::4, _::binary>> = data), do: Publish.decode(data) - def decode(<<4::4, _::4, _::binary>> = data), do: Puback.decode(data) - def decode(<<5::4, _::4, _::binary>> = data), do: Pubrec.decode(data) - def decode(<<6::4, _::4, _::binary>> = data), do: Pubrel.decode(data) - def decode(<<7::4, _::4, _::binary>> = data), do: Pubcomp.decode(data) - def decode(<<8::4, _::4, _::binary>> = data), do: Subscribe.decode(data) - def decode(<<9::4, _::4, _::binary>> = data), do: Suback.decode(data) - def decode(<<10::4, _::4, _::binary>> = data), do: Unsubscribe.decode(data) - def decode(<<11::4, _::4, _::binary>> = data), do: Unsuback.decode(data) - def decode(<<12::4, _::4, _::binary>> = data), do: Pingreq.decode(data) - def decode(<<13::4, _::4, _::binary>> = data), do: Pingresp.decode(data) - def decode(<<14::4, _::4, _::binary>> = data), do: Disconnect.decode(data) + def decode(<<1::4, _::4, _::binary>> = data, opts), do: Connect.decode(data, opts) + def decode(<<2::4, _::4, _::binary>> = data, opts), do: Connack.decode(data, opts) + def decode(<<3::4, _::4, _::binary>> = data, opts), do: Publish.decode(data, opts) + def decode(<<4::4, _::4, _::binary>> = data, opts), do: Puback.decode(data, opts) + def decode(<<5::4, _::4, _::binary>> = data, opts), do: Pubrec.decode(data, opts) + def decode(<<6::4, _::4, _::binary>> = data, opts), do: Pubrel.decode(data, opts) + def decode(<<7::4, _::4, _::binary>> = data, opts), do: Pubcomp.decode(data, opts) + def decode(<<8::4, _::4, _::binary>> = data, opts), do: Subscribe.decode(data, opts) + def decode(<<9::4, _::4, _::binary>> = data, opts), do: Suback.decode(data, opts) + def decode(<<10::4, _::4, _::binary>> = data, opts), do: Unsubscribe.decode(data, opts) + def decode(<<11::4, _::4, _::binary>> = data, opts), do: Unsuback.decode(data, opts) + def decode(<<12::4, _::4, _::binary>> = data, opts), do: Pingreq.decode(data, opts) + def decode(<<13::4, _::4, _::binary>> = data, opts), do: Pingresp.decode(data, opts) + def decode(<<14::4, _::4, _::binary>> = data, opts), do: Disconnect.decode(data, opts) + def decode(<<15::4, _::4, _::binary>> = data, opts), do: Auth.decode(data, opts) end defimpl Tortoise.Decodable, for: List do - def decode(data) do + def decode(data, opts) do data |> IO.iodata_to_binary() - |> Tortoise.Decodable.decode() + |> Tortoise.Decodable.decode(opts) end end diff --git a/lib/tortoise/encodable.ex b/lib/tortoise/encodable.ex index 09b8c6a6..02d38791 100644 --- a/lib/tortoise/encodable.ex +++ b/lib/tortoise/encodable.ex @@ -1,6 +1,6 @@ defprotocol Tortoise.Encodable do @moduledoc false - @spec encode(t) :: iodata() - def encode(package) + @spec encode(t, Keyword.t()) :: iodata() + def encode(package, opts) end diff --git a/lib/tortoise/events.ex b/lib/tortoise/events.ex deleted file mode 100644 index 8214a9cc..00000000 --- a/lib/tortoise/events.ex +++ /dev/null @@ -1,70 +0,0 @@ -defmodule Tortoise.Events do - @moduledoc """ - A PubSub exposing various system events from a Tortoise - connection. This allows the user to integrate with custom metrics - and logging solutions. - - Please read the documentation for `Tortoise.Events.register/2` for - information on how to subscribe to events, and - `Tortoise.Events.unregister/2` for how to unsubscribe. - """ - - @types [:connection, :status, :ping_response] - - @doc """ - Subscribe to messages on the client with the client id `client_id` - of the type `type`. - - When a message of the subscribed type is dispatched it will end up - in the mailbox of the process that placed the subscription. The - received message will have the format: - - {{Tortoise, client_id}, type, value} - - Making it possible to pattern match on multiple message types on - multiple clients. The value depends on the message type. - - Possible message types are: - - - `:status` dispatched when the connection of a client changes - status. The value will be `:up` when the client goes online, and - `:down` when it goes offline. - - - `:ping_response` dispatched when the connection receive a - response from a keep alive message. The value is the round trip - time in milliseconds, and can be used to track the latency over - time. - - Other message types exist, but unless they are mentioned in the - possible message types above they should be considered for internal - use only. - - It is possible to listen on all events for a given type by - specifying `:_` as the `client_id`. - """ - @spec register(Tortoise.client_id(), atom()) :: {:ok, pid()} | no_return() - def register(client_id, type) when type in @types do - {:ok, _pid} = Registry.register(__MODULE__, type, client_id) - end - - @doc """ - Unsubscribe from messages of `type` from `client_id`. This is the - reverse of `Tortoise.Events.register/2`. - """ - @spec unregister(Tortoise.client_id(), atom()) :: :ok | no_return() - def unregister(client_id, type) when type in @types do - :ok = Registry.unregister_match(__MODULE__, type, client_id) - end - - @doc false - @spec dispatch(Tortoise.client_id(), type :: atom(), value :: term()) :: :ok - def dispatch(client_id, type, value) when type in @types do - :ok = - Registry.dispatch(__MODULE__, type, fn subscribers -> - for {pid, filter} <- subscribers, - filter == client_id or filter == :_ do - Kernel.send(pid, {{Tortoise, client_id}, type, value}) - end - end) - end -end diff --git a/lib/tortoise/generatable.ex b/lib/tortoise/generatable.ex new file mode 100644 index 00000000..991561c4 --- /dev/null +++ b/lib/tortoise/generatable.ex @@ -0,0 +1,121 @@ +defprotocol Tortoise.Generatable do + @moduledoc false + + # TODO add spec + def generate(data) +end + +if Code.ensure_loaded?(StreamData) do + defmodule Tortoise.Generatable.Topic do + import StreamData + + # "/" is a valid topic, becomes ["", ""] in tortoise, which is a + # special case because topic levels are not allowed to be empty: + # they must be a string of at least one character. It is also + # allowed to start a topic with a slash "/foo" which is different + # from "foo". In tortoise, when the user is given the topic + # levels, the former if represented as `["", "foo"]`. + + @doc """ + Generate a random MQTT topic + """ + def gen_topic() do + # generate a list of nils that will get replaced by topic level + # generators + bind(list_of(nil), &gen_topic/1) + end + + @doc """ + Generate a topic based on an input + + TODO fix this documentation + """ + # Let the smallest element that can be created be "/", which in + # out internal representation is represented as `["", ""]`. + def gen_topic([]), do: constant(["", ""]) + + # short circuit one level long topics + def gen_topic([topic_level]) do + fixed_list([gen_topic_level(topic_level)]) + end + + # from now on we are dealing with lists of length > 1 + def gen_topic([_ | _] = input) do + bind(constant(input), fn + [nil | topic_list] -> + fixed_list([ + frequency([ + # the first topic level is allowed to be empty which + # will translate into a topic that starts with a slash + # ("/foo", which is different from "foo") + {4, gen_topic_level(nil)}, + {1, constant("")} + ]) + | for(topic_level <- topic_list, do: gen_topic_level(topic_level)) + ]) + + topic_list -> + fixed_list(for topic_level <- topic_list, do: gen_topic_level(topic_level)) + end) + end + + # generate the topic level names + defp gen_topic_level(nil) do + bind(string(:ascii, min_length: 1), fn topic_level -> + constant(String.replace(topic_level, ["#", "+", "/"], "_")) + end) + end + + defp gen_topic_level(%StreamData{} = generator), do: generator + defp gen_topic_level(<>), do: constant(literal) + + @doc """ + Generate a random topic filter + """ + def gen_filter() do + bind(list_of(nil), &gen_filter/1) + end + + @doc """ + Generate a topic filter based on a topic + + The resulting topic filter will be one that matches the given + topic, so `["foo", "bar"]` will result in topic filters such as + `["foo", "+"]`, `["#"]`, `["+", "#"]`, etc. + """ + def gen_filter(input) do + gen_topic(input) + |> bind(&maybe_add_multi_level_filter/1) + |> bind(&mutate_topic_levels/1) + end + + defp maybe_add_multi_level_filter(topic_levels) do + frequency([ + {4, constant(topic_levels)}, + {1, bind(constant(topic_levels), &add_multi_level_filter/1)} + ]) + end + + defp add_multi_level_filter([_ | _] = topic_list) do + start = length(topic_list) * -1 + + bind(integer(start..-1), fn position -> + constant(Enum.drop(topic_list, position) ++ ["#"]) + end) + end + + defp mutate_topic_levels(topic_levels) do + fixed_list(for topic_level <- topic_levels, do: do_mutate_topic_level(topic_level)) + end + + defp do_mutate_topic_level("+"), do: constant("+") + defp do_mutate_topic_level("#"), do: constant("#") + + defp do_mutate_topic_level(topic_level) do + frequency([ + {1, constant("+")}, + {2, constant(topic_level)} + ]) + end + end +end diff --git a/lib/tortoise/handler.ex b/lib/tortoise/handler.ex index 627cfe92..5234feeb 100644 --- a/lib/tortoise/handler.ex +++ b/lib/tortoise/handler.ex @@ -25,7 +25,7 @@ defmodule Tortoise.Handler do behavior for when the subscription is accepted, declined, as well as unsubscribed. - - `handle_message/3` is run when the client receive a message on + - `handle_publish/3` is run when the client receive a publish on one of the subscribed topic filters. Because the callback-module will run inside the connection @@ -63,7 +63,7 @@ defmodule Tortoise.Handler do require the user to peek into the process mailbox to fetch the result of the operation. To allow for changes in the subscriptions one can define a set of next actions that should happen as part of - the return value to the `handle_message/3`, `subscription/3`, and + the return value to the `handle_publish/3`, `subscription/3`, and `connection/3` callbacks by returning a `{:ok, state, next_actions}` where `next_actions` is a list of commands of: @@ -78,18 +78,16 @@ defmodule Tortoise.Handler do from. If we want to unsubscribe from the current topic when we receive a - message on it we could write a `handle_message/3` as follows: + publish on it we could write a `handle_publish/3` as follows: - def handle_message(topic, _payload, state) do - topic = Enum.join(topic, "/") + def handle_publish(_, %{topic: topic}, state) do next_actions = [{:unsubscribe, topic}] - {:ok, state, next_actions} + {:cont, state, next_actions} end - Note that the `topic` is received as a list of topic levels, and - that the next actions has to be a list, even if there is only one - next action; multiple actions can be given at once. Read more about - this in the `handle_message/3` documentation. + The next actions has to be a list, even if there is only one next + action; multiple actions can be given at once. Read more about this + in the `handle_publish/3` documentation. """ alias Tortoise.Package @@ -130,22 +128,50 @@ defmodule Tortoise.Handler do end @impl true - def terminate(_reason, _state) do - :ok + def handle_connack(%Package.Connack{reason: :success}, state) do + {:cont, state} + end + + def handle_connack(%Package.Connack{reason: {:refused, reason}}, _state) do + # todo, we could categorize the reasons into user error, + # network error, etc error... + case reason do + :unsupported_protocol_version -> + {:error, {:connection_failed, :unsupported_protocol_version}} + + :not_authorized -> + {:error, {:connection_failed, :not_authorized}} + + :server_unavailable -> + {:error, {:connection_failed, :server_unavailable}} + + :client_identifier_not_valid -> + {:error, {:connection_failed, :client_identifier_not_valid}} + + :bad_user_name_or_password -> + {:error, {:connection_failed, :bad_user_name_or_password}} + + # todo, list not exhaustive + end end @impl true - def connection(_status, state) do - {:ok, state} + def handle_publish(_topic_list, _publish, state) do + {:cont, state} end @impl true - def subscription(_status, _topic_filter, state) do - {:ok, state} + def handle_suback(_subscribe, _suback, state) do + {:cont, state} end @impl true - def handle_message(_topic, _payload, state) do + def handle_unsuback(_unsubscribe, _unsuback, state) do + {:cont, state} + end + + @impl true + def handle_disconnect(_disconnect, state) do {:ok, state} end @@ -194,59 +220,35 @@ defmodule Tortoise.Handler do a list of next actions such as `{:unsubscribe, "foo/bar"}` will result in the state being returned and the next actions performed. """ - @callback connection(status, state :: term()) :: - {:ok, new_state} - | {:ok, new_state, [next_action()]} + @callback status_change(status, state :: term()) :: + {:cont, new_state} + | {:cont, new_state, [next_action()]} when status: :up | :down, new_state: term() - @doc """ - Invoked when the subscription of a topic filter changes status. - - The `status` of a subscription can be one of: - - - `:up`, triggered when the subscription has been accepted by the - MQTT broker with the requested quality of service - - - `{:warn, [requested: req_qos, accepted: qos]}`, triggered when - the subscription is accepted by the MQTT broker, but with a - different quality of service `qos` than the one requested - `req_qos` - - - `{:error, reason}`, triggered when the subscription is rejected - with the reason `reason` such as `:access_denied` - - - `:down`, triggered when the subscription of the given topic - filter has been successfully acknowledged as unsubscribed by the - MQTT broker - - The `topic_filter` is the topic filter in question, and the `state` - is the internal state being passed through transitions. - - Returning `{:ok, new_state}` will set the state for later - invocations. - - Returning `{:ok, new_state, next_actions}`, where `next_actions` is - a list of next actions such as `{:unsubscribe, "foo/bar"}` will - result in the state being returned and the next actions performed. - """ - @callback subscription(status, topic_filter, state :: term) :: + @callback handle_connack(connack, state :: term()) :: {:ok, new_state} | {:ok, new_state, [next_action()]} - when status: - :up - | :down - | {:warn, [requested: Tortoise.qos(), accepted: Tortoise.qos()]} - | {:error, term()}, - topic_filter: Tortoise.topic_filter(), - new_state: term + | {:error, reason :: term()} + when connack: Package.Connack.t(), + new_state: term() + + @callback handle_suback(subscribe, suback, state :: term) :: {:ok, new_state} + when subscribe: Package.Subscribe.t(), + suback: Package.Suback.t(), + new_state: term() + + @callback handle_unsuback(unsubscribe, unsuback, state :: term) :: {:ok, new_state} + when unsubscribe: Package.Unsubscribe.t(), + unsuback: Package.Unsuback.t(), + new_state: term() @doc """ Invoked when messages are published to subscribed topics. The `topic` comes in the form of a list of binaries, making it possible to pattern match on the topic levels of the retrieved - message, store the individual topic levels as variables and use it + publish, store the individual topic levels as variables and use it in the function body. `Payload` is a binary. MQTT 3.1.1 does not specify any format of the @@ -255,15 +257,15 @@ defmodule Tortoise.Handler do In an example where we are already subscribed to the topic filter `room/+/temp` and want to dispatch the received messages to a - `Temperature` application we could set up our `handle_message` as + `Temperature` application we could set up our `handle_publish` as such: - def handle_message(["room", room, "temp"], payload, state) do - :ok = Temperature.record(room, payload) - {:ok, state} + def handle_publish(["room", room, "temp"], publish, state) do + :ok = Temperature.record(room, publish.payload) + {:cont, state} end - Notice; the `handle_message/3`-callback run inside the connection + Notice; the `handle_publish/3`-callback run inside the connection controller process, so for handlers that are subscribing to topics with heavy traffic should do as little as possible in the callback handler and dispatch to other parts of the application using @@ -276,13 +278,36 @@ defmodule Tortoise.Handler do a list of next actions such as `{:unsubscribe, "foo/bar"}` will reenter the loop and perform the listed actions. """ - @callback handle_message(topic_levels, payload, state :: term()) :: - {:ok, new_state} - | {:ok, new_state, [next_action()]} + @callback handle_publish(topic_levels, payload, state :: term()) :: + {:cont, new_state} + | {:cont, new_state, [next_action()]} when new_state: term(), topic_levels: [String.t()], payload: Tortoise.payload() + @callback handle_puback(puback, state :: term()) :: {:ok, new_state} + when puback: Package.Puback.t(), + new_state: term() + + @callback handle_pubrec(pubrec, state :: term()) :: {:ok, new_state} + when pubrec: Package.Pubrec.t(), + new_state: term() + + @callback handle_pubrel(pubrel, state :: term()) :: {:ok, new_state} + when pubrel: Package.Pubrel.t(), + new_state: term() + + @callback handle_pubcomp(pubcomp, state :: term()) :: {:ok, new_state} + when pubcomp: Package.Pubcomp.t(), + new_state: term() + + @callback handle_disconnect(disconnect, state :: term()) :: {:ok, new_state} + when source: :server | :network, + disconnect: {source, Package.Disconnect.t()}, + new_state: term() + + # todo, should we do handle_pingresp as well ? + @doc """ Invoked when the connection process is about to exit. @@ -293,121 +318,320 @@ defmodule Tortoise.Handler do when reason: :normal | :shutdown | {:shutdown, term()}, ignored: term() + @optional_callbacks status_change: 2, + handle_pubrec: 2, + handle_pubrel: 2, + handle_pubcomp: 2, + handle_puback: 2, + terminate: 2 + @doc false - @spec execute(t, action) :: :ok | {:ok, t} | {:error, {:invalid_next_action, term()}} - when action: - :init - | {:subscribe, [term()]} - | {:unsubscribe, [term()]} - | {:publish, Tortoise.Package.Publish.t()} - | {:connection, :up | :down} - | {:terminate, reason :: term()} - def execute(handler, :init) do - case apply(handler.module, :init, [handler.initial_args]) do + @spec execute_init(t) :: {:ok, t} | :ignore | {:stop, term()} + def execute_init(handler) do + handler.module + |> apply(:init, [handler.initial_args]) + |> case do {:ok, initial_state} -> {:ok, %__MODULE__{handler | state: initial_state}} + + :ignore -> + :ignore + + {:stop, reason} -> + {:stop, reason} end end - def execute(handler, {:connection, status}) do - handler.module - |> apply(:connection, [status, handler.state]) - |> handle_result(handler) + # todo, fix the type spec here so it contain the next actions and + # error path as well + @doc false + @spec execute_status_change(t, status) :: {:ok, t} + when status: :up | :down + def execute_status_change(handler, status) do + apply(handler.module, :status_change, [status, handler.state]) + |> transform_result() + |> case do + {:cont, updated_state, next_actions} -> + updated_handler = %__MODULE__{handler | state: updated_state} + {:ok, updated_handler, next_actions} + + {:error, reason} -> + {:error, reason} + end + end + + @spec execute_handle_connack(t, Package.Connack.t()) :: + {:ok, t} | {:error, {:invalid_next_action, term()}} + def execute_handle_connack(handler, %Package.Connack{} = connack) do + apply(handler.module, :handle_connack, [connack, handler.state]) + |> transform_result() + |> case do + {:cont, updated_state, next_actions} -> + updated_handler = %__MODULE__{handler | state: updated_state} + {:ok, updated_handler, next_actions} + + {:stop, reason, updated_state} -> + {:stop, reason, %__MODULE__{handler | state: updated_state}} + + {:error, reason} -> + {:error, reason} + end + end + + @doc false + @spec execute_handle_disconnect(t, disconnect) :: {:stop, term(), t} + when disconnect: {:server, %Package.Disconnect{}} | {:network, atom()} + def execute_handle_disconnect(handler, {source, _reason} = disconnect) + when source in [:server, :network] do + apply(handler.module, :handle_disconnect, [disconnect, handler.state]) + |> transform_result() + |> case do + {:cont, updated_state, next_actions} -> + updated_handler = %__MODULE__{handler | state: updated_state} + {:ok, updated_handler, next_actions} + + {:stop, reason, updated_state} -> + {:stop, reason, %__MODULE__{handler | state: updated_state}} + end + end + + @doc false + @spec execute_terminate(t, reason) :: ignored + when reason: term(), + ignored: term() + def execute_terminate(handler, reason) do + _ignored = apply(handler.module, :terminate, [reason, handler.state]) end - def execute(handler, {:publish, %Package.Publish{} = publish}) do + @doc false + @spec execute_handle_suback(t, Package.Subscribe.t(), Package.Suback.t()) :: {:ok, t} + def execute_handle_suback(handler, subscribe, suback) do + apply(handler.module, :handle_suback, [subscribe, suback, handler.state]) + |> transform_result() + |> case do + {:cont, updated_state, next_actions} -> + updated_handler = %__MODULE__{handler | state: updated_state} + {:ok, updated_handler, next_actions} + + {:error, reason} -> + {:error, reason} + end + end + + @doc false + @spec execute_handle_unsuback(t, Package.Unsubscribe.t(), Package.Unsuback.t()) :: + {:ok, t, [any()]} + def execute_handle_unsuback(handler, unsubscribe, unsuback) do + apply(handler.module, :handle_unsuback, [unsubscribe, unsuback, handler.state]) + |> transform_result() + |> case do + {:cont, updated_state, next_actions} -> + updated_handler = %__MODULE__{handler | state: updated_state} + {:ok, updated_handler, next_actions} + + {:error, reason} -> + {:error, reason} + end + end + + @doc false + @spec execute_handle_publish(t, Package.Publish.t()) :: + {:ok, t} | {:error, {:invalid_next_action, term()}} + def execute_handle_publish(handler, %Package.Publish{qos: 0} = publish) do topic_list = String.split(publish.topic, "/") - handler.module - |> apply(:handle_message, [topic_list, publish.payload, handler.state]) - |> handle_result(handler) + apply(handler.module, :handle_publish, [topic_list, publish, handler.state]) + |> transform_result() + |> case do + {:cont, updated_state, next_actions} -> + updated_handler = %__MODULE__{handler | state: updated_state} + {:ok, updated_handler, next_actions} + + {:error, reason} -> + {:error, reason} + end + end + + def execute_handle_publish(handler, %Package.Publish{identifier: id, qos: 1} = publish) do + topic_list = String.split(publish.topic, "/") + + apply(handler.module, :handle_publish, [topic_list, publish, handler.state]) + |> transform_result() + |> case do + {:cont, updated_state, next_actions} -> + puback = %Package.Puback{identifier: id} + updated_handler = %__MODULE__{handler | state: updated_state} + {:ok, puback, updated_handler, next_actions} + + {{:cont, properties}, updated_state, next_actions} when is_list(properties) -> + puback = %Package.Puback{identifier: id, properties: properties} + updated_handler = %__MODULE__{handler | state: updated_state} + {:ok, puback, updated_handler, next_actions} + + {:error, reason} -> + {:error, reason} + end end - def execute(handler, {:unsubscribe, unsubacks}) do - Enum.reduce(unsubacks, {:ok, handler}, fn topic_filter, {:ok, handler} -> - handler.module - |> apply(:subscription, [:down, topic_filter, handler.state]) - |> handle_result(handler) + def execute_handle_publish(handler, %Package.Publish{identifier: id, qos: 2} = publish) do + topic_list = String.split(publish.topic, "/") - # _, {:stop, acc} -> - # {:stop, acc} - end) + apply(handler.module, :handle_publish, [topic_list, publish, handler.state]) + |> transform_result() + |> case do + {:cont, updated_state, next_actions} -> + pubrec = %Package.Pubrec{identifier: id} + updated_handler = %__MODULE__{handler | state: updated_state} + {:ok, pubrec, updated_handler, next_actions} + + {{:cont, properties}, updated_state, next_actions} when is_list(properties) -> + pubrec = %Package.Pubrec{identifier: id, properties: properties} + updated_handler = %__MODULE__{handler | state: updated_state} + {:ok, pubrec, updated_handler, next_actions} + + {:error, reason} -> + {:error, reason} + end end - def execute(handler, {:subscribe, subacks}) do - subacks - |> flatten_subacks() - |> Enum.reduce({:ok, handler}, fn {op, topic_filter}, {:ok, handler} -> - handler.module - |> apply(:subscription, [op, topic_filter, handler.state]) - |> handle_result(handler) - - # _, {:stop, acc} -> - # {:stop, acc} - end) + @doc false + # @spec execute_handle_puback(t, Package.Puback.t()) :: + # {:ok, t} | {:error, {:invalid_next_action, term()}} + def execute_handle_puback(handler, %Package.Puback{} = puback) do + apply(handler.module, :handle_puback, [puback, handler.state]) + |> transform_result() + |> case do + {:cont, updated_state, next_actions} -> + updated_handler = %__MODULE__{handler | state: updated_state} + {:ok, updated_handler, next_actions} + + {:error, reason} -> + {:error, reason} + end end - def execute(handler, {:terminate, reason}) do - _ignored = apply(handler.module, :terminate, [reason, handler.state]) - :ok + @doc false + # @spec execute_handle_pubrec(t, Package.Pubrec.t()) :: + # {:ok, t} | {:error, {:invalid_next_action, term()}} + def execute_handle_pubrec(handler, %Package.Pubrec{identifier: id} = pubrec) do + apply(handler.module, :handle_pubrec, [pubrec, handler.state]) + |> transform_result() + |> case do + {:cont, updated_state, next_actions} -> + pubrel = %Package.Pubrel{identifier: id} + updated_handler = %__MODULE__{handler | state: updated_state} + {:ok, pubrel, updated_handler, next_actions} + + {{:cont, properties}, updated_state, next_actions} when is_list(properties) -> + pubrel = %Package.Pubrel{identifier: id, properties: properties} + updated_handler = %__MODULE__{handler | state: updated_state} + {:ok, pubrel, updated_handler, next_actions} + + {{:cont, %Package.Pubrel{identifier: ^id} = pubrel}, updated_state, next_actions} -> + updated_handler = %__MODULE__{handler | state: updated_state} + {:ok, pubrel, updated_handler, next_actions} + + {:error, reason} -> + {:error, reason} + end + end + + @doc false + # @spec execute_handle_pubrel(t, Package.Pubrel.t()) :: + # {:ok, t} | {:error, {:invalid_next_action, term()}} + def execute_handle_pubrel(handler, %Package.Pubrel{identifier: id} = pubrel) do + apply(handler.module, :handle_pubrel, [pubrel, handler.state]) + |> transform_result() + |> case do + {:cont, updated_state, next_actions} -> + pubcomp = %Package.Pubcomp{identifier: id} + updated_handler = %__MODULE__{handler | state: updated_state} + {:ok, pubcomp, updated_handler, next_actions} + + {{:cont, properties}, updated_state, next_actions} when is_list(properties) -> + pubcomp = %Package.Pubcomp{identifier: id, properties: properties} + updated_handler = %__MODULE__{handler | state: updated_state} + {:ok, pubcomp, updated_handler, next_actions} + + {{:cont, %Package.Pubcomp{identifier: ^id} = pubcomp}, updated_state, next_actions} -> + updated_handler = %__MODULE__{handler | state: updated_state} + {:ok, pubcomp, updated_handler, next_actions} + + {:error, reason} -> + {:error, reason} + end end - # Subacks will come in a map with three keys in the form of tuples - # where the fist element is one of `:ok`, `:warn`, or `:error`. This - # is done to make it easy to pattern match in other parts of the - # system, and error out early if the result set contain errors. In - # this part of the system it is more convenient to transform the - # data to a flat list containing tuples of `{operation, data}` so we - # can reduce the handler state to collect the possible next actions, - # and pass through if there is an :error or :disconnect return. - defp flatten_subacks(subacks) do - Enum.reduce(subacks, [], fn - {_, []}, acc -> - acc - - {:ok, entries}, acc -> - for {topic_filter, _qos} <- entries do - {:up, topic_filter} - end ++ acc - - {:warn, entries}, acc -> - for {topic_filter, warning} <- entries do - {{:warn, warning}, topic_filter} - end ++ acc - - {:error, entries}, acc -> - for {reason, {topic_filter, _qos}} <- entries do - {{:error, reason}, topic_filter} - end ++ acc - end) + @doc false + # @spec execute_handle_pubcomp(t, Package.Pubcomp.t()) :: + # {:ok, t} | {:error, {:invalid_next_action, term()}} + def execute_handle_pubcomp(handler, %Package.Pubcomp{} = pubcomp) do + apply(handler.module, :handle_pubcomp, [pubcomp, handler.state]) + |> transform_result() + |> case do + {:cont, updated_state, next_actions} -> + updated_handler = %__MODULE__{handler | state: updated_state} + {:ok, updated_handler, next_actions} + end end - # handle the user defined return from the callback - defp handle_result({:ok, updated_state}, handler) do - {:ok, %__MODULE__{handler | state: updated_state}} + defp transform_result({:stop, reason, updated_state}) do + {:stop, reason, updated_state} end - defp handle_result({:ok, updated_state, next_actions}, handler) - when is_list(next_actions) do + defp transform_result({:error, reason}) do + {:error, reason} + end + + defp transform_result({cont, updated_state, next_actions}) when is_list(next_actions) do case Enum.split_with(next_actions, &valid_next_action?/1) do - {next_actions, []} -> - # send the next actions to the process mailbox. Notice that - # this code is run in the context of the connection controller - for action <- next_actions, do: send(self(), {:next_action, action}) - {:ok, %__MODULE__{handler | state: updated_state}} - - {_, errors} -> - {:error, {:invalid_next_action, errors}} + {_, []} -> + # add option lists to next actions that do not have them yet, + # this could probably be done in a smarter way; a function + # that both validate and coerce the arguments in one pass + coerced_next_actions = + for action <- next_actions do + case action do + {:subscribe, topic} -> + {:subscribe, topic, []} + + {:unsubscribe, topic} -> + {:unsubscribe, topic, []} + + otherwise -> + otherwise + end + end + + {cont, updated_state, coerced_next_actions} + + {_, invalid_next_actions} -> + {:error, {:invalid_next_action, invalid_next_actions}} end end + defp transform_result({cont, updated_state}) do + transform_result({cont, updated_state, []}) + end + + # subscribe + defp valid_next_action?({:subscribe, topic}), do: is_binary(topic) + defp valid_next_action?({:subscribe, topic, opts}) do is_binary(topic) and is_list(opts) end - defp valid_next_action?({:unsubscribe, topic}) do - is_binary(topic) + # unsubscribe + defp valid_next_action?({:unsubscribe, topic}), do: is_binary(topic) + + defp valid_next_action?({:unsubscribe, topic, opts}) do + is_binary(topic) and is_list(opts) end + # disconnect + defp valid_next_action?(:disconnect), do: true + + defp valid_next_action?({:eval, fun}) when is_function(fun, 1), do: true + defp valid_next_action?(_otherwise), do: false end diff --git a/lib/tortoise/handler/logger.ex b/lib/tortoise/handler/logger.ex index 1b89698c..10cd3b82 100644 --- a/lib/tortoise/handler/logger.ex +++ b/lib/tortoise/handler/logger.ex @@ -3,56 +3,109 @@ defmodule Tortoise.Handler.Logger do require Logger + alias Tortoise.Package + use Tortoise.Handler defstruct [] alias __MODULE__, as: State + @impl true def init(_opts) do Logger.info("Initializing handler") {:ok, %State{}} end - def connection(:up, state) do + @impl true + def status_change(:up, state) do Logger.info("Connection has been established") - {:ok, state} + {:cont, state} end - def connection(:down, state) do + def status_change(:down, state) do Logger.warn("Connection has been dropped") - {:ok, state} + {:cont, state} end - def connection(:terminating, state) do + def status_change(:terminating, state) do Logger.warn("Connection is terminating") - {:ok, state} + {:cont, state} end - def subscription(:up, topic, state) do - Logger.info("Subscribed to #{topic}") - {:ok, state} + @impl true + def handle_connack(%Package.Connack{reason: :success}, state) do + Logger.info("Successfully connected to the server") + {:cont, state} end - def subscription({:warn, [requested: req, accepted: qos]}, topic, state) do - Logger.warn("Subscribed to #{topic}; requested #{req} but got accepted with QoS #{qos}") - {:ok, state} + def handle_connack(%Package.Connack{reason: {:refused, refusal}}, state) do + Logger.error("Server refused connection: #{inspect(refusal)}") + {:cont, state} end - def subscription({:error, reason}, topic, state) do - Logger.error("Error subscribing to #{topic}; #{inspect(reason)}") - {:ok, state} + @impl true + def handle_suback(%Package.Subscribe{} = subscribe, %Package.Suback{} = suback, state) do + for {{topic, opts}, result} <- Enum.zip(subscribe.topics, suback.acks) do + case {opts[:qos], result} do + {qos, {:ok, qos}} -> + Logger.info("Subscribed to #{topic} with the expected qos: #{qos}") + + {req_qos, {:ok, accepted_qos}} -> + Logger.warn( + "Subscribed to #{topic} with QoS #{req_qos} but got accepted as #{accepted_qos}" + ) + + {_, {:error, reason}} -> + Logger.error("Failed to subscribe to topic: #{topic} reason: #{inspect(reason)}") + end + end + + {:cont, state} end - def subscription(:down, topic, state) do - Logger.info("Unsubscribed from #{topic}") - {:ok, state} + @impl true + def handle_unsuback(%Package.Unsubscribe{} = unsubscribe, %Package.Unsuback{} = unsuback, state) do + for {topic, result} <- Enum.zip(unsubscribe.topics, unsuback.results) do + case result do + :success -> + Logger.info("Successfully unsubscribed from #{topic}") + end + end + + {:cont, state} end - def handle_message(topic, publish, state) do + @impl true + def handle_publish(topic, %Package.Publish{} = publish, state) do Logger.info("#{Enum.join(topic, "/")} #{inspect(publish)}") - {:ok, state} + {:cont, state} + end + + @impl true + def handle_puback(%Package.Puback{} = puback, state) do + Logger.info("Puback: #{puback.identifier}") + {:cont, state} + end + + @impl true + def handle_pubrec(%Package.Pubrec{} = pubrec, state) do + Logger.info("Pubrec: #{pubrec.identifier}") + {:cont, state} + end + + @impl true + def handle_pubcomp(%Package.Pubcomp{} = pubcomp, state) do + Logger.info("Pubcomp: #{pubcomp.identifier}") + {:cont, state} + end + + @impl true + def handle_disconnect(%Package.Disconnect{} = disconnect, state) do + Logger.info("Received disconnect from server #{inspect(disconnect)}") + {:cont, state} end + @impl true def terminate(reason, _state) do Logger.warn("Client has been terminated with reason: #{inspect(reason)}") :ok diff --git a/lib/tortoise/package.ex b/lib/tortoise/package.ex index c8a0f455..5dd3d38a 100644 --- a/lib/tortoise/package.ex +++ b/lib/tortoise/package.ex @@ -18,9 +18,11 @@ defmodule Tortoise.Package do | Package.Pingreq.t() | Package.Pingresp.t() | Package.Disconnect.t() + | Package.Auth.t() - defdelegate encode(data), to: Tortoise.Encodable + defdelegate encode(data, opts \\ []), to: Tortoise.Encodable defdelegate decode(data), to: Tortoise.Decodable + defdelegate generate(package), to: Tortoise.Generatable @doc false def length_encode(data) do @@ -28,6 +30,11 @@ defmodule Tortoise.Package do [length_prefix, data] end + @doc false + def variable_length(n) do + remaining_length(n) + end + @doc false def variable_length_encode(data) when is_list(data) do length_prefix = data |> IO.iodata_length() |> remaining_length() @@ -40,4 +47,41 @@ defmodule Tortoise.Package do defp remaining_length(n) do [<<1::1, rem(n, @highbit)::7>>] ++ remaining_length(div(n, @highbit)) end + + @doc false + def drop_length_prefix(payload) do + case payload do + <<0::1, _::7, r::binary>> -> r + <<1::1, _::7, 0::1, _::7, r::binary>> -> r + <<1::1, _::7, 1::1, _::7, 0::1, _::7, r::binary>> -> r + <<1::1, _::7, 1::1, _::7, 1::1, _::7, 0::1, _::7, r::binary>> -> r + end + end + + def parse_variable_length(data) do + case data do + <<0::1, length::integer-size(7), _::binary>> -> + length = length + 1 + <> = data + {properties, rest} + + <<1::1, a::7, 0::1, b::7, _::binary>> -> + <> = <> + length = length + 2 + <> = data + {properties, rest} + + <<1::1, a::7, 1::1, b::7, 0::1, c::7, _::binary>> -> + <> = <> + length = length + 3 + <> = data + {properties, rest} + + <<1::1, a::7, 1::1, b::7, 1::1, c::7, 0::1, d::7, _::binary>> -> + <> = <> + length = length + 4 + <> = data + {properties, rest} + end + end end diff --git a/lib/tortoise/package/auth.ex b/lib/tortoise/package/auth.ex new file mode 100644 index 00000000..2714aaad --- /dev/null +++ b/lib/tortoise/package/auth.ex @@ -0,0 +1,133 @@ +defmodule Tortoise.Package.Auth do + @moduledoc false + + @opcode 15 + + # @allowed_properties [:authentication_method, :authentication_data, :reason_string, :user_property] + + alias Tortoise.Package + + @type reason :: :success | :continue_authentication | :re_authenticate + + @opaque t :: %__MODULE__{ + __META__: Package.Meta.t(), + reason: reason(), + properties: [{any(), any()}] + } + @enforce_keys [:reason] + defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0}, + reason: nil, + properties: [] + + @spec decode(binary(), opts :: Keyword.t()) :: t + def decode(<<@opcode::4, 0::4, 0>>, _opts) do + %__MODULE__{reason: coerce_reason_code(0x00)} + end + + def decode(<<@opcode::4, 0::4, variable_header::binary>>, _opts) do + <> = Package.drop_length_prefix(variable_header) + + %__MODULE__{ + reason: coerce_reason_code(reason_code), + properties: Package.Properties.decode(properties) + } + end + + defp coerce_reason_code(reason_code) do + case reason_code do + 0x00 -> :success + 0x18 -> :continue_authentication + 0x19 -> :re_authenticate + end + end + + defimpl Tortoise.Encodable do + def encode(%Package.Auth{reason: :success, properties: []} = t, _opts) do + [Package.Meta.encode(t.__META__), 0] + end + + def encode(%Package.Auth{reason: reason} = t, _opts) + when reason in [:success, :continue_authentication, :re_authenticate] do + [ + Package.Meta.encode(t.__META__), + Package.variable_length_encode([ + <>, + Package.Properties.encode(t.properties) + ]) + ] + end + + defp to_reason_code(reason) do + case reason do + :success -> 0x00 + :continue_authentication -> 0x18 + :re_authenticate -> 0x19 + end + end + end + + if Code.ensure_loaded?(StreamData) do + defimpl Tortoise.Generatable do + import StreamData + + def generate(%type{__META__: _meta} = package) do + values = package |> Map.from_struct() + + fixed_list(Enum.map(values, &constant(&1))) + |> bind(&gen_reason/1) + |> bind(&gen_properties/1) + |> bind(&fixed_map([{:__struct__, type} | for({k, v} <- &1, do: {k, constant(v)})])) + end + + @reasons [ + :success, + :continue_authentication, + :re_authenticate + ] + + defp gen_reason(values) do + case Keyword.pop(values, :reason) do + {nil, values} -> + fixed_list([{:reason, one_of(@reasons)} | Enum.map(values, &constant(&1))]) + + {reason, _} when reason in @reasons -> + constant(values) + end + end + + # If the initial CONNECT packet included an Authentication + # Method property then all AUTH packets, and any successful + # CONNACK packet MUST include an Authentication Method Property + # with the same value as in the CONNECT packet [MQTT-4.12.0-5]. + + defp gen_properties(values) do + case Keyword.pop(values, :properties) do + {nil, values} -> + properties = + uniq_list_of( + frequency([ + # here we allow stings with a byte size of zero; don't + # know if that is a problem according to the spec. Let's + # handle that situation just in case: + {4, {:user_property, {string(:printable), string(:printable)}}}, + # TODO authentication method should always be present + {1, {:authentication_method, string(:printable)}}, + {1, {:authentication_data, string(:printable)}}, + {1, {:reason_string, string(:printable)}} + ]), + uniq_fun: &uniq/1, + max_length: 5 + ) + + fixed_list([{:properties, properties} | Enum.map(values, &constant(&1))]) + + {_passthrough, _} -> + constant(values) + end + end + + defp uniq({:user_property, _v}), do: :crypto.strong_rand_bytes(2) + defp uniq({k, _v}), do: k + end + end +end diff --git a/lib/tortoise/package/connack.ex b/lib/tortoise/package/connack.ex index 5fbd8f95..6320a430 100644 --- a/lib/tortoise/package/connack.ex +++ b/lib/tortoise/package/connack.ex @@ -3,67 +3,268 @@ defmodule Tortoise.Package.Connack do @opcode 2 + # @allowed_properties [:assigned_client_identifier, :authentication_data, :authentication_method, :maximum_packet_size, :maximum_qos, :reason_string, :receive_maximum, :response_information, :retain_available, :server_keep_alive, :server_reference, :session_expiry_interval, :shared_subscription_available, :subscription_identifier_available, :topic_alias_maximum, :user_property, :wildcard_subscription_available] + alias Tortoise.Package - @type status :: :accepted | {:refused, refusal_reasons()} + @type reason :: :success | {:refused, refusal_reasons()} @type refusal_reasons :: - :unacceptable_protocol_version - | :identifier_rejected - | :server_unavailable + :unspecified_error + | :malformed_packet + | :protocol_error + | :implementation_specific_error + | :unsupported_protocol_version + | :client_identifier_not_valid | :bad_user_name_or_password | :not_authorized + | :server_unavailable + | :server_busy + | :banned + | :bad_authentication_method + | :topic_name_invalid + | :packet_too_large + | :quota_exceeded + | :payload_format_invalid + | :retain_not_supported + | :qos_not_supported + | :use_another_server + | :server_moved + | :connection_rate_exceeded @opaque t :: %__MODULE__{ __META__: Package.Meta.t(), session_present: boolean(), - status: status() | nil + reason: reason(), + properties: [{any(), any()}] } - @enforce_keys [:status] + @enforce_keys [:reason] defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0}, session_present: false, - status: nil + reason: :success, + properties: [] + + @spec decode(binary(), opts :: Keyword.t()) :: t + def decode(<<@opcode::4, 0::4, variable_header::binary>>, _opts) do + <<0::7, session_present::1, reason_code::8, properties::binary>> = + Package.drop_length_prefix(variable_header) - @spec decode(<<_::32>>) :: t - def decode(<<@opcode::4, 0::4, 2, 0::7, session_present::1, return_code::8>>) do %__MODULE__{ session_present: session_present == 1, - status: coerce_return_code(return_code) + reason: coerce_reason_code(reason_code), + properties: Package.Properties.decode(properties) } end - defp coerce_return_code(return_code) do - case return_code do - 0x00 -> :accepted - 0x01 -> {:refused, :unacceptable_protocol_version} - 0x02 -> {:refused, :identifier_rejected} - 0x03 -> {:refused, :server_unavailable} - 0x04 -> {:refused, :bad_user_name_or_password} - 0x05 -> {:refused, :not_authorized} + defp coerce_reason_code(reason_code) do + case reason_code do + 0x00 -> :success + 0x80 -> {:refused, :unspecified_error} + 0x81 -> {:refused, :malformed_packet} + 0x82 -> {:refused, :protocol_error} + 0x83 -> {:refused, :implementation_specific_error} + 0x84 -> {:refused, :unsupported_protocol_version} + 0x85 -> {:refused, :client_identifier_not_valid} + 0x86 -> {:refused, :bad_user_name_or_password} + 0x87 -> {:refused, :not_authorized} + 0x88 -> {:refused, :server_unavailable} + 0x89 -> {:refused, :server_busy} + 0x8A -> {:refused, :banned} + 0x8C -> {:refused, :bad_authentication_method} + 0x90 -> {:refused, :topic_name_invalid} + 0x95 -> {:refused, :packet_too_large} + 0x97 -> {:refused, :quota_exceeded} + 0x99 -> {:refused, :payload_format_invalid} + 0x9A -> {:refused, :retain_not_supported} + 0x9B -> {:refused, :qos_not_supported} + 0x9C -> {:refused, :use_another_server} + 0x9D -> {:refused, :server_moved} + 0x9F -> {:refused, :connection_rate_exceeded} end end defimpl Tortoise.Encodable do - def encode(%Package.Connack{session_present: session_present, status: status} = t) - when status != nil do + def encode(%Package.Connack{} = t, _opts) do [ Package.Meta.encode(t.__META__), - <<2, 0::7, flag(session_present)::1, to_return_code(status)::8>> + Package.variable_length_encode([ + <<0::7, flag(t.session_present)::1, to_reason_code(t.reason)::8>>, + Package.Properties.encode(t.properties) + ]) ] end - defp to_return_code(:accepted), do: 0x00 + defp to_reason_code(:success), do: 0x00 - defp to_return_code({:refused, reason}) do + defp to_reason_code({:refused, reason}) do case reason do - :unacceptable_protocol_version -> 0x01 - :identifier_rejected -> 0x02 - :server_unavailable -> 0x03 - :bad_user_name_or_password -> 0x04 - :not_authorized -> 0x05 + :unspecified_error -> 0x80 + :malformed_packet -> 0x81 + :protocol_error -> 0x82 + :implementation_specific_error -> 0x83 + :unsupported_protocol_version -> 0x84 + :client_identifier_not_valid -> 0x85 + :bad_user_name_or_password -> 0x86 + :not_authorized -> 0x87 + :server_unavailable -> 0x88 + :server_busy -> 0x89 + :banned -> 0x8A + :bad_authentication_method -> 0x8C + :topic_name_invalid -> 0x90 + :packet_too_large -> 0x95 + :quota_exceeded -> 0x97 + :payload_format_invalid -> 0x99 + :retain_not_supported -> 0x9A + :qos_not_supported -> 0x9B + :use_another_server -> 0x9C + :server_moved -> 0x9D + :connection_rate_exceeded -> 0x9F end end defp flag(f) when f in [0, nil, false], do: 0 defp flag(_), do: 1 end + + if Code.ensure_loaded?(StreamData) do + defimpl Tortoise.Generatable do + import StreamData + + def generate(%type{__META__: _meta} = package) do + values = package |> Map.from_struct() + + fixed_list(Enum.map(values, &constant(&1))) + |> bind(&gen_reason/1) + |> bind(&gen_session_present/1) + |> bind(&gen_properties/1) + |> bind(fn data -> + fixed_map([ + {:__struct__, type} + | for({k, v} <- data, do: {k, constant(v)}) + ]) + end) + end + + @refusals [ + :unspecified_error, + :malformed_packet, + :protocol_error, + :implementation_specific_error, + :unsupported_protocol_version, + :client_identifier_not_valid, + :bad_user_name_or_password, + :not_authorized, + :server_unavailable, + :server_busy, + :banned, + :bad_authentication_method, + :topic_name_invalid, + :packet_too_large, + :quota_exceeded, + :payload_format_invalid, + :retain_not_supported, + :qos_not_supported, + :use_another_server, + :server_moved, + :connection_rate_exceeded + ] + + defp gen_reason(values) do + case Keyword.pop(values, :reason) do + {nil, values} -> + fixed_list([ + { + constant(:reason), + StreamData.frequency([ + {60, constant(:success)}, + {40, tuple({constant(:refused), one_of(@refusals)})} + ]) + } + | Enum.map(values, &constant(&1)) + ]) + + {{:refused, nil}, values} -> + fixed_list([ + {:reason, tuple({constant(:refused), one_of(@refusals)})} + | Enum.map(values, &constant(&1)) + ]) + + {:success, _} -> + constant(values) + + {{:refused, refusal_reason}, _} when refusal_reason in @refusals -> + constant(values) + end + end + + defp gen_session_present(values) do + case Keyword.get(values, :reason) do + :success -> + case Keyword.pop(values, :session_present) do + {nil, values} -> + fixed_list([ + {constant(:session_present), boolean()} + | Enum.map(values, &constant(&1)) + ]) + + {_passthrough, _} -> + constant(values) + end + + {:refused, _refusal_reason} -> + # There will not be a session if the connection is refused + constant(Keyword.put(values, :session_present, false)) + end + end + + defp gen_properties(values) do + case Keyword.pop(values, :properties) do + {nil, values} -> + properties = + uniq_list_of( + one_of([ + # here we allow stings with a byte size of zero; don't + # know if that is a problem according to the spec. Let's + # handle that situation just in case: + {constant(:user_property), {string(:printable), string(:printable)}}, + {constant(:assigned_client_identifier), + string(:printable, min_length: 1, max_length: 23)}, + {constant(:maximum_packet_size), integer(1..0xFFFFFFFF)}, + {constant(:maximum_qos), integer(0..1)}, + {constant(:reason_string), string(:printable)}, + {constant(:receive_maximum), integer(0..0xFFFF)}, + {constant(:retain_available), boolean()}, + # TODO don't know if zero is a valid keep alive + {constant(:server_keep_alive), integer(0..0xFFFF)}, + {constant(:session_expiry_interval), integer(0..0xFFFF)}, + {constant(:shared_subscription_available), boolean()}, + {constant(:subscription_identifiers_available), boolean()}, + {constant(:topic_alias_maximum), integer(0..0xFFFF)}, + {constant(:wildcard_subscription_available), boolean()} + + # TODO, generator that generate valid server references + # {constant(:server_reference), boolean()}, + # TODO, generator that generate valid response info + # {constant(:response_information), boolean()}, + # TODO, generate auth data and methods + # {constant(:authentication_data), boolean()}, + # {constant(:authentication_method), boolean()} + ]), + uniq_fun: &uniq/1, + max_length: 5 + ) + + fixed_list([ + {constant(:properties), properties} + | Enum.map(values, &constant(&1)) + ]) + + {_passthrough, _} -> + constant(values) + end + end + + defp uniq({:user_property, _v}), do: :crypto.strong_rand_bytes(2) + defp uniq({k, _v}), do: k + end + end end diff --git a/lib/tortoise/package/connect.ex b/lib/tortoise/package/connect.ex index 561ee596..8371c544 100644 --- a/lib/tortoise/package/connect.ex +++ b/lib/tortoise/package/connect.ex @@ -3,6 +3,18 @@ defmodule Tortoise.Package.Connect do @opcode 1 + # @allowed_properties [ + # :authentication_data, + # :authentication_method, + # :maximum_packet_size, + # :receive_maximum, + # :request_problem_information, + # :request_response_information, + # :session_expiry_interval, + # :topic_alias_maximum, + # :user_property + # ] + alias Tortoise.Package @opaque t :: %__MODULE__{ @@ -11,58 +23,103 @@ defmodule Tortoise.Package.Connect do protocol_version: non_neg_integer(), user_name: binary() | nil, password: binary() | nil, - clean_session: boolean(), + clean_start: boolean(), keep_alive: non_neg_integer(), client_id: Tortoise.client_id(), - will: Package.Publish.t() | nil + will: Package.Publish.t() | nil, + properties: [{any(), any()}] } - @enforce_keys [:client_id] + defstruct __META__: %Package.Meta{opcode: @opcode}, protocol: "MQTT", - protocol_version: 0b00000100, + protocol_version: 0b00000101, user_name: nil, password: nil, - clean_session: true, + clean_start: true, keep_alive: 60, client_id: nil, - will: nil + will: nil, + properties: [] - @spec decode(binary()) :: t - def decode(<<@opcode::4, 0::4, variable::binary>>) do - <<4::big-integer-size(16), "MQTT", 4::8, user_name::1, password::1, will_retain::1, - will_qos::2, will::1, clean_session::1, 0::1, keep_alive::big-integer-size(16), - package::binary>> = drop_length(variable) + @spec decode(binary(), opts :: Keyword.t()) :: t + def decode(<<@opcode::4, 0::4, variable::binary>>, _opts) do + << + 4::big-integer-size(16), + "MQTT", + 5::8, + user_name::1, + password::1, + will_retain::1, + will_qos::2, + will::1, + clean_start::1, + 0::1, + keep_alive::big-integer-size(16), + rest::binary + >> = drop_length(variable) - options = - [ - client_id: 1, - will_topic: will, - will_payload: will, - user_name: user_name, - password: password - ] - |> Enum.filter(fn {_, present} -> present == 1 end) - |> Enum.map(fn {value, 1} -> value end) - |> Enum.zip(decode_length_prefixed(package)) + {properties, package} = Package.parse_variable_length(rest) + properties = Package.Properties.decode(properties) + + payload = + decode_payload( + [ + client_id: true, + will_properties: will == 1, + will_topic: will == 1, + will_payload: will == 1, + user_name: user_name == 1, + password: password == 1 + ], + package + ) %__MODULE__{ - client_id: options[:client_id], - user_name: options[:user_name], - password: options[:password], + client_id: payload[:client_id], + user_name: payload[:user_name], + password: payload[:password], will: if will == 1 do %Package.Publish{ - topic: options[:will_topic], - payload: nullify(options[:will_payload]), + topic: payload[:will_topic], + payload: nullify(payload[:will_payload]), qos: will_qos, - retain: will_retain == 1 + retain: will_retain == 1, + properties: payload[:will_properties] } + |> Package.Meta.infer() end, - clean_session: clean_session == 1, - keep_alive: keep_alive + clean_start: clean_start == 1, + keep_alive: keep_alive, + properties: properties } end + defp decode_payload([], <<>>) do + [] + end + + defp decode_payload([{_ignored, false} | remaining_fields], payload) do + decode_payload(remaining_fields, payload) + end + + defp decode_payload( + [{:will_properties, true} | remaining_fields], + payload + ) do + {properties, rest} = Package.parse_variable_length(payload) + value = Package.Properties.decode(properties) + [{:will_properties, value}] ++ decode_payload(remaining_fields, rest) + end + + defp decode_payload( + [{field, true} | remaining_fields], + <> + ) do + <> = payload + [{field, value}] ++ decode_payload(remaining_fields, rest) + end + defp nullify(""), do: nil defp nullify(payload), do: payload @@ -75,15 +132,8 @@ defmodule Tortoise.Package.Connect do end end - defp decode_length_prefixed(<<>>), do: [] - - defp decode_length_prefixed(<>) do - <> = payload - [item] ++ decode_length_prefixed(rest) - end - defimpl Tortoise.Encodable do - def encode(%Package.Connect{client_id: client_id} = t) + def encode(%Package.Connect{client_id: client_id} = t, _opts) when is_binary(client_id) do [ Package.Meta.encode(t.__META__), @@ -91,14 +141,15 @@ defmodule Tortoise.Package.Connect do protocol_header(t), connection_flags(t), keep_alive(t), + Package.Properties.encode(t.properties), payload(t) ]) ] end - def encode(%Package.Connect{client_id: client_id} = t) + def encode(%Package.Connect{client_id: client_id} = t, opts) when is_atom(client_id) do - encode(%Package.Connect{t | client_id: Atom.to_string(client_id)}) + encode(%Package.Connect{t | client_id: Atom.to_string(client_id)}, opts) end defp protocol_header(%{protocol: protocol, protocol_version: version}) do @@ -115,7 +166,7 @@ defmodule Tortoise.Package.Connect do 0::integer-size(2), # will flag flag(0)::integer-size(1), - flag(f.clean_session)::integer-size(1), + flag(f.clean_start)::integer-size(1), # reserved bit 0::1 >> @@ -128,7 +179,7 @@ defmodule Tortoise.Package.Connect do flag(f.will.retain)::integer-size(1), f.will.qos::integer-size(2), flag(f.will.topic)::integer-size(1), - flag(f.clean_session)::integer-size(1), + flag(f.clean_start)::integer-size(1), # reserved bit 0::1 >> @@ -145,11 +196,25 @@ defmodule Tortoise.Package.Connect do end defp payload(f) do - will_payload = encode_payload(f.will.payload) + options = [ + f.client_id, + f.will.properties, + f.will.topic, + encode_payload(f.will.payload), + f.user_name, + f.password + ] - [f.client_id, f.will.topic, will_payload, f.user_name, f.password] - |> Enum.filter(&is_binary/1) - |> Enum.map(&Package.length_encode/1) + for data <- options, + data != nil do + case data do + data when is_binary(data) -> + Package.length_encode(data) + + data when is_list(data) -> + Package.Properties.encode(data) + end + end end defp encode_payload(nil), do: "" @@ -158,4 +223,296 @@ defmodule Tortoise.Package.Connect do defp flag(f) when f in [0, nil, false], do: 0 defp flag(_), do: 1 end + + if Code.ensure_loaded?(StreamData) do + defimpl Tortoise.Generatable do + import StreamData + + alias Tortoise.Generatable.Topic + + def generate(%type{__META__: _meta} = package) do + values = package |> Map.from_struct() + + fixed_list(Enum.map(values, &constant(&1))) + |> bind(&gen_user_name/1) + |> bind(&gen_password/1) + |> bind(&gen_clean_start/1) + |> bind(&gen_keep_alive/1) + |> bind(&gen_client_id/1) + |> bind(&gen_will/1) + |> bind(&gen_properties/1) + |> bind(fn data -> + fixed_map([ + {:__struct__, type} + | for({k, v} <- data, do: {k, constant(v)}) + ]) + end) + end + + defp gen_clean_start(values) do + case Keyword.pop(values, :clean_start) do + {nil, values} -> + fixed_list([ + {:clean_start, boolean()} + | Enum.map(values, &constant(&1)) + ]) + + {bool, _} when is_boolean(bool) -> + constant(values) + + # User specified generators should produce a boolean + {%StreamData{} = generator, values} -> + bind(generator, fn + bool when is_boolean(bool) -> + fixed_list([ + {:clean_start, bool} + | Enum.map(values, &constant(&1)) + ]) + + _otherwise -> + raise ArgumentError, "Clean start generator should produce a boolean" + end) + end + end + + defp gen_keep_alive(values) do + case Keyword.pop(values, :keep_alive) do + {nil, values} -> + fixed_list([ + {:keep_alive, + frequency([ + # Most of the time we will produce a reasonable keep + # alive value; somewhere between 1 and 5 minutes + {4, integer(60..300)}, + # Sometimes produce a *very* long keep alive + # interval, the longest the MQTT spec support is a + # bit more than 18 hours! + {4, integer(301..0xFFFF)}, + # Sometimes produce a value less than a minute + {1, integer(1..59)}, + # A value of zero means that the client will not + # send ping requests on a particular schedule, and + # the server will not kick the client if it does + # not; this essentially turns the keep alive off + {1, constant(0)} + ])} + | Enum.map(values, &constant(&1)) + ]) + + {value, _} when is_integer(value) and value in 0..0xFFFF -> + constant(values) + + # User specified generators should produce an integer + {%StreamData{} = generator, values} -> + bind(generator, fn + value when is_integer(value) and value in 0..0xFFFF -> + fixed_list([ + {:keep_alive, constant(value)} + | Enum.map(values, &constant(&1)) + ]) + + _otherwise -> + raise ArgumentError, """ + Keep alive generator should produce an integer between 0 and 65_535 + """ + end) + end + end + + defp gen_client_id(values) do + case Keyword.pop(values, :client_id) do + {nil, values} -> + fixed_list([ + {:client_id, + frequency([ + # A server should accept a client id between 1 and 23 + # chars in length consisting of 0-9, a-z, and A-Z. + {1, gen_client_id_string()}, + # The server may accept a client id longer than 23 + # chars, and of any chars + {1, string(:printable, min_length: 1)} + # If the client id is nil the server may assign a + # client id and send it as a property in the connack + # message + # {1, nil} + ])} + | Enum.map(values, &constant(&1)) + ]) + + {%StreamData{} = generator, values} -> + bind(generator, fn + client_id when is_binary(client_id) or is_nil(client_id) -> + fixed_list([ + {:client_id, constant(client_id)} + | Enum.map(values, &constant(&1)) + ]) + + _otherwise -> + raise ArgumentError, "A client id should be nil or a binary" + end) + + {<<_::binary>>, _} -> + constant(values) + end + end + + defp gen_client_id_string() do + list_of( + one_of([integer(?A..?Z), integer(?a..?z), integer(?0..?9)]), + min_length: 1, + max_length: 23 + ) + |> bind(&constant(List.to_string(&1))) + end + + defp gen_user_name(values) do + case Keyword.pop(values, :user_name) do + {nil, values} -> + fixed_list([ + {:user_name, one_of([nil, string(:printable)])} + | Enum.map(values, &constant(&1)) + ]) + + {%StreamData{} = generator, values} -> + bind(generator, fn + user_name when is_binary(user_name) or is_nil(user_name) -> + fixed_list([ + {:user_name, constant(user_name)} + | Enum.map(values, &constant(&1)) + ]) + + _otherwise -> + raise ArgumentError, "User name should be nil or a binary" + end) + + {<<_::binary>>, _} -> + constant(values) + end + end + + defp gen_password(values) do + case Keyword.pop(values, :password) do + {nil, values} -> + fixed_list([ + {:password, one_of([nil, binary()])} + | Enum.map(values, &constant(&1)) + ]) + + {%StreamData{} = generator, values} -> + bind(generator, fn + password when is_binary(password) or is_nil(password) -> + fixed_list([ + {:password, constant(password)} + | Enum.map(values, &constant(&1)) + ]) + + _otherwise -> + raise ArgumentError, "Password should be nil or a binary" + end) + + {<<_::binary>>, _} -> + constant(values) + end + end + + defp gen_will(values) do + case Keyword.pop(values, :will) do + {nil, values} -> + fixed_list([ + {:will, + Package.generate(%Package.Publish{ + identifier: constant(nil), + dup: false, + retain: nil, + qos: nil, + properties: gen_will_properties() + })} + | Enum.map(values, &constant(&1)) + ]) + + {%Package.Publish{}, _values} -> + constant(values) + end + end + + defp gen_will_properties() do + uniq_list_of( + # Use frequency to make it more likely to pick a user + # property as we are allowed to have multiple of them; + # the remaining properties may only occur once, + # without the weights we could end up in situations + # where StreamData gives up because it cannot find any + # candidates that hasn't been chosen before + frequency([ + # here we allow stings with a byte size of zero; don't + # know if that is a problem according to the spec. Let's + # handle that situation just in case: + {6, {:user_property, {string(:printable), string(:printable)}}}, + {1, {:will_delay_interval, integer(0..0xFFFFFFFF)}}, + {1, {:payload_format_indicator, integer(0..1)}}, + {1, {:message_expiry_interval, integer(0..0xFFFFFFFF)}}, + {1, {:content_type, string(:printable)}}, + {1, {:response_topic, bind(Topic.gen_topic(), &constant(Enum.join(&1, "/")))}}, + {1, {:correlation_data, binary()}} + ]), + uniq_fun: &uniq/1, + max_length: 10 + ) + end + + defp gen_properties(values) do + case Keyword.pop(values, :properties) do + {nil, values} -> + properties = + uniq_list_of( + # Use frequency to make it more likely to pick a user + # property as we are allowed to have multiple of them; + # the remaining properties may only occur once, + # without the weights we could end up in situations + # where StreamData gives up because it cannot find any + # candidates that hasn't been chosen before + frequency([ + # here we allow stings with a byte size of zero; don't + # know if that is a problem according to the spec. Let's + # handle that situation just in case: + {8, {:user_property, {string(:printable), string(:printable)}}}, + {1, {:maximum_packet_size, integer(1..0xFFFFFFFF)}}, + {1, {:receive_maximum, integer(1..0xFFFF)}}, + {1, {:request_problem_information, boolean()}}, + {1, {:request_response_information, boolean()}}, + {1, {:session_expiry_interval, integer(0..0xFFFFFFFF)}}, + {1, {:topic_alias_maximum, integer(0..0xFFFF)}}, + {1, {:authentication_method, string(:printable)}} + ]), + uniq_fun: &uniq/1, + max_length: 10 + ) + |> bind(fn properties -> + if Keyword.has_key?(properties, :authentication_method) do + one_of([ + constant(properties), + fixed_list([ + {:authentication_data, binary()} + | Enum.map(properties, &constant(&1)) + ]) + ]) + else + constant(properties) + end + end) + + fixed_list([ + {constant(:properties), properties} + | Enum.map(values, &constant(&1)) + ]) + + {_passthrough, _} -> + constant(values) + end + end + + defp uniq({:user_property, _v}), do: :crypto.strong_rand_bytes(2) + defp uniq({k, _v}), do: k + end + end end diff --git a/lib/tortoise/package/disconnect.ex b/lib/tortoise/package/disconnect.ex index a9ee1fc8..0330b618 100644 --- a/lib/tortoise/package/disconnect.ex +++ b/lib/tortoise/package/disconnect.ex @@ -3,20 +3,260 @@ defmodule Tortoise.Package.Disconnect do @opcode 14 + # @allowed_properties [:reason_string, :server_reference, :session_expiry_interval, :user_property] + alias Tortoise.Package + @type reason :: + :normal_disconnection + | :disconnect_with_will_message + | :unspecified_error + | :malformed_packet + | :protocol_error + | :implementation_specific_error + | :not_authorized + | :server_busy + | :server_shutting_down + | :keep_alive_timeout + | :session_taken_over + | :topic_filter_invalid + | :topic_name_invalid + | :receive_maximum_exceeded + | :topic_alias_invalid + | :packet_too_large + | :message_rate_too_high + | :quota_exceeded + | :administrative_action + | :payload_format_invalid + | :retain_not_supported + | :qos_not_supported + | :use_another_server + | :server_moved + | :shared_subscriptions_not_supported + | :connection_rate_exceeded + | :maximum_connect_time + | :subscription_identifiers_not_supported + | :wildcard_subscriptions_not_supported + @opaque t :: %__MODULE__{ - __META__: Package.Meta.t() + __META__: Package.Meta.t(), + reason: reason(), + # todo, let this live in the properties module + properties: [{atom(), any()}] } - defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0} + defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0}, + reason: :normal_disconnection, + properties: [] + + @spec decode(binary(), opts :: Keyword.t()) :: t + def decode(<<@opcode::4, 0::4, 0::8>>, _opts) do + # If the Remaining Length is less than 1 the value of 0x00 (Normal + # disconnection) is used + %__MODULE__{reason: coerce_reason_code(0x00)} + end + + def decode(<<@opcode::4, 0::4, variable_header::binary>>, _opts) do + <> = drop_length_prefix(variable_header) + + %__MODULE__{ + reason: coerce_reason_code(reason_code), + properties: Package.Properties.decode(properties) + } + end - @spec decode(<<_::16>>) :: t - def decode(<<@opcode::4, 0::4, 0>>), do: %__MODULE__{} + defp coerce_reason_code(reason_code) do + case reason_code do + 0x00 -> :normal_disconnection + 0x04 -> :disconnect_with_will_message + 0x80 -> :unspecified_error + 0x81 -> :malformed_packet + 0x82 -> :protocol_error + 0x83 -> :implementation_specific_error + 0x87 -> :not_authorized + 0x89 -> :server_busy + 0x8B -> :server_shutting_down + 0x8D -> :keep_alive_timeout + 0x8E -> :session_taken_over + 0x8F -> :topic_filter_invalid + 0x90 -> :topic_name_invalid + 0x93 -> :receive_maximum_exceeded + 0x94 -> :topic_alias_invalid + 0x95 -> :packet_too_large + 0x96 -> :message_rate_too_high + 0x97 -> :quota_exceeded + 0x98 -> :administrative_action + 0x99 -> :payload_format_invalid + 0x9A -> :retain_not_supported + 0x9B -> :qos_not_supported + 0x9C -> :use_another_server + 0x9D -> :server_moved + 0x9E -> :shared_subscriptions_not_supported + 0x9F -> :connection_rate_exceeded + 0xA0 -> :maximum_connect_time + 0xA1 -> :subscription_identifiers_not_supported + 0xA2 -> :wildcard_subscriptions_not_supported + end + end + + defp drop_length_prefix(payload) do + case payload do + <<0::1, _::7, r::binary>> -> r + <<1::1, _::7, 0::1, _::7, r::binary>> -> r + <<1::1, _::7, 1::1, _::7, 0::1, _::7, r::binary>> -> r + <<1::1, _::7, 1::1, _::7, 1::1, _::7, 0::1, _::7, r::binary>> -> r + end + end # Protocols ---------------------------------------------------------- defimpl Tortoise.Encodable do - def encode(%Package.Disconnect{} = t) do + def encode(%Package.Disconnect{reason: :normal_disconnection, properties: []} = t, _opts) do [Package.Meta.encode(t.__META__), 0] end + + def encode(%Package.Disconnect{} = t, _opts) do + [ + Package.Meta.encode(t.__META__), + Package.variable_length_encode([ + <>, + Package.Properties.encode(t.properties) + ]) + ] + end + + defp to_reason_code(reason) do + case reason do + :normal_disconnection -> 0x00 + :disconnect_with_will_message -> 0x04 + :unspecified_error -> 0x80 + :malformed_packet -> 0x81 + :protocol_error -> 0x82 + :implementation_specific_error -> 0x83 + :not_authorized -> 0x87 + :server_busy -> 0x89 + :server_shutting_down -> 0x8B + :keep_alive_timeout -> 0x8D + :session_taken_over -> 0x8E + :topic_filter_invalid -> 0x8F + :topic_name_invalid -> 0x90 + :receive_maximum_exceeded -> 0x93 + :topic_alias_invalid -> 0x94 + :packet_too_large -> 0x95 + :message_rate_too_high -> 0x96 + :quota_exceeded -> 0x97 + :administrative_action -> 0x98 + :payload_format_invalid -> 0x99 + :retain_not_supported -> 0x9A + :qos_not_supported -> 0x9B + :use_another_server -> 0x9C + :server_moved -> 0x9D + :shared_subscriptions_not_supported -> 0x9E + :connection_rate_exceeded -> 0x9F + :maximum_connect_time -> 0xA0 + :subscription_identifiers_not_supported -> 0xA1 + :wildcard_subscriptions_not_supported -> 0xA2 + end + end + end + + if Code.ensure_loaded?(StreamData) do + defimpl Tortoise.Generatable do + import StreamData + + def generate(%type{__META__: _meta} = package) do + values = package |> Map.from_struct() + + fixed_list(Enum.map(values, &constant(&1))) + |> bind(&gen_reason/1) + |> bind(&gen_properties/1) + |> bind(fn data -> + fixed_map([ + {:__struct__, type} + | for({k, v} <- data, do: {k, constant(v)}) + ]) + end) + end + + @reasons [ + :normal_disconnection, + :disconnect_with_will_message, + :unspecified_error, + :malformed_packet, + :protocol_error, + :implementation_specific_error, + :not_authorized, + :server_busy, + :server_shutting_down, + :keep_alive_timeout, + :session_taken_over, + :topic_filter_invalid, + :topic_name_invalid, + :receive_maximum_exceeded, + :topic_alias_invalid, + :packet_too_large, + :message_rate_too_high, + :quota_exceeded, + :administrative_action, + :payload_format_invalid, + :retain_not_supported, + :qos_not_supported, + :use_another_server, + :server_moved, + :shared_subscriptions_not_supported, + :connection_rate_exceeded, + :maximum_connect_time, + :subscription_identifiers_not_supported, + :wildcard_subscriptions_not_supported + ] + + defp gen_reason(values) do + case Keyword.pop(values, :reason) do + {nil, values} -> + fixed_list([ + {constant(:reason), one_of(@reasons)} + | Enum.map(values, &constant(&1)) + ]) + + {reason, _} when reason in @reasons -> + constant(values) + end + end + + defp gen_properties(values) do + case Keyword.pop(values, :properties) do + {nil, values} -> + properties = + uniq_list_of( + # Use frequency to make it more likely to pick a user + # property as we are allowed to have multiple of them; + # the remaining properties may only occur once, + # without the weights we could end up in situations + # where StreamData gives up because it cannot find any + # candidates that hasn't been chosen before + frequency([ + # here we allow stings with a byte size of zero; don't + # know if that is a problem according to the spec. Let's + # handle that situation just in case: + {5, {constant(:user_property), {string(:printable), string(:printable)}}}, + {1, {constant(:reason_string), string(:printable)}}, + {1, {constant(:session_expiry_interval), integer(0..0xFFFFFFFF)}} + # TODO generate valid :server_reference, + ]), + uniq_fun: &uniq/1, + max_length: 10 + ) + + fixed_list([ + {constant(:properties), properties} + | Enum.map(values, &constant(&1)) + ]) + + {_passthrough, _} -> + constant(values) + end + end + + defp uniq({:user_property, _v}), do: :crypto.strong_rand_bytes(2) + defp uniq({k, _v}), do: k + end end end diff --git a/lib/tortoise/package/meta.ex b/lib/tortoise/package/meta.ex index 4d25dc29..34b038e4 100644 --- a/lib/tortoise/package/meta.ex +++ b/lib/tortoise/package/meta.ex @@ -1,6 +1,8 @@ defmodule Tortoise.Package.Meta do @moduledoc false + alias Tortoise.Package + @opaque t() :: %__MODULE__{ opcode: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14, flags: non_neg_integer() @@ -11,4 +13,20 @@ defmodule Tortoise.Package.Meta do def encode(meta) do <> end + + @doc """ + Infer the meta values from the package content and type + """ + def infer(%Package.Publish{dup: dup, qos: qos, retain: retain} = data) do + <> = <> + infered_meta = %__MODULE__{opcode: 3, flags: flags} + %Package.Publish{data | __META__: infered_meta} + end + + def infer(%_type{__META__: _} = data) do + data + end + + defp flag(true), do: 1 + defp flag(false), do: 0 end diff --git a/lib/tortoise/package/pingreq.ex b/lib/tortoise/package/pingreq.ex index 527f3361..26ef0e72 100644 --- a/lib/tortoise/package/pingreq.ex +++ b/lib/tortoise/package/pingreq.ex @@ -10,15 +10,35 @@ defmodule Tortoise.Package.Pingreq do } defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0} - @spec decode(<<_::16>>) :: t - def decode(<<@opcode::4, 0::4, 0>>) do + @spec decode(<<_::16>>, opts :: Keyword.t()) :: t + def decode(<<@opcode::4, 0::4, 0>>, _opts) do %__MODULE__{} end # Protocols ---------------------------------------------------------- defimpl Tortoise.Encodable do - def encode(%Package.Pingreq{} = t) do + # Note: The Pingreq package is the same for both version 3.1.1 and + # version 5, no options apply + def encode(%Package.Pingreq{} = t, _opts) do [Package.Meta.encode(t.__META__), 0] end end + + if Code.ensure_loaded?(StreamData) do + defimpl Tortoise.Generatable do + import StreamData + + def generate(%type{__META__: _meta} = package) do + values = package |> Map.from_struct() + + fixed_list(Enum.map(values, &constant(&1))) + |> bind(fn data -> + fixed_map([ + {:__struct__, type} + | for({k, v} <- data, do: {k, constant(v)}) + ]) + end) + end + end + end end diff --git a/lib/tortoise/package/pingresp.ex b/lib/tortoise/package/pingresp.ex index 5f42ed6e..113716d5 100644 --- a/lib/tortoise/package/pingresp.ex +++ b/lib/tortoise/package/pingresp.ex @@ -10,15 +10,33 @@ defmodule Tortoise.Package.Pingresp do } defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0} - @spec decode(<<_::16>>) :: t - def decode(<<@opcode::4, 0::4, 0>>) do + @spec decode(<<_::16>>, opts :: Keyword.t()) :: t + def decode(<<@opcode::4, 0::4, 0>>, _opts) do %__MODULE__{} end # Protocols ---------------------------------------------------------- defimpl Tortoise.Encodable do - def encode(%Package.Pingresp{} = t) do + def encode(%Package.Pingresp{} = t, _opts) do [Package.Meta.encode(t.__META__), 0] end end + + if Code.ensure_loaded?(StreamData) do + defimpl Tortoise.Generatable do + import StreamData + + def generate(%type{__META__: _meta} = package) do + values = package |> Map.from_struct() + + fixed_list(Enum.map(values, &constant(&1))) + |> bind(fn data -> + fixed_map([ + {:__struct__, type} + | for({k, v} <- data, do: {k, constant(v)}) + ]) + end) + end + end + end end diff --git a/lib/tortoise/package/properties.ex b/lib/tortoise/package/properties.ex new file mode 100644 index 00000000..9901ee7e --- /dev/null +++ b/lib/tortoise/package/properties.ex @@ -0,0 +1,275 @@ +defmodule Tortoise.Package.Properties do + @moduledoc false + + alias Tortoise.Package + + import Tortoise.Package, only: [variable_length: 1, length_encode: 1] + + def encode(data) when is_list(data) do + data + |> Enum.map(&encode_property/1) + |> Package.variable_length_encode() + end + + # The `user_property` property should be specified as + # `{:user_property, {key, value}}` where both `key` and `value` are + # UTF-8 encoded strings. User properties with the same key are + # allowed, and by specifying it like this we make it possible to + # specify the order of the properties. + defp encode_property({:user_property, {<>, <>}}) do + [0x26, length_encode(key), length_encode(value)] + end + + # We allow the user to specify a list of key/value pairs when + # multiple user properties are needed; all items in the list must be + # 2-tuples of string/string. + defp encode_property({:user_property, [{<<_::binary>>, <<_::binary>>} | _] = properties}) do + for property <- properties, do: encode_property({:user_property, property}) + end + + # Ignore the user property if an empty list is passed in + defp encode_property({:user_property, []}) do + <<>> + end + + defp encode_property({key, value}) do + case key do + :payload_format_indicator when value in [0, 1] -> + [0x01, <>] + + :message_expiry_interval -> + [0x02, <>] + + :content_type -> + [0x03, length_encode(value)] + + :response_topic -> + [0x08, length_encode(value)] + + :correlation_data -> + [0x09, length_encode(value)] + + :subscription_identifier when is_integer(value) -> + [0x0B, variable_length(value)] + + :session_expiry_interval -> + [0x11, <>] + + :assigned_client_identifier -> + [0x12, length_encode(value)] + + :server_keep_alive -> + [0x13, <>] + + :authentication_method -> + [0x15, length_encode(value)] + + :authentication_data when is_binary(value) -> + [0x16, length_encode(value)] + + :request_problem_information when is_boolean(value) -> + [0x17, boolean_to_byte(value)] + + :will_delay_interval when is_integer(value) -> + [0x18, <>] + + :request_response_information when is_boolean(value) -> + [0x19, boolean_to_byte(value)] + + :response_information -> + [0x1A, length_encode(value)] + + :server_reference -> + [0x1C, length_encode(value)] + + :reason_string -> + [0x1F, length_encode(value)] + + :receive_maximum -> + [0x21, <>] + + :topic_alias_maximum -> + [0x22, <>] + + :topic_alias -> + [0x23, <>] + + :maximum_qos when value in [0, 1] -> + [0x24, <>] + + :retain_available when is_boolean(value) -> + [0x25, boolean_to_byte(value)] + + :maximum_packet_size -> + [0x27, <>] + + :wildcard_subscription_available when is_boolean(value) -> + [0x28, boolean_to_byte(value)] + + :subscription_identifiers_available when is_boolean(value) -> + [0x29, boolean_to_byte(value)] + + :shared_subscription_available when is_boolean(value) -> + [0x2A, boolean_to_byte(value)] + end + end + + defp boolean_to_byte(true), do: <<1::8>> + defp boolean_to_byte(false), do: <<0::8>> + + # --- + def decode(data) do + data + |> Package.drop_length_prefix() + |> do_decode() + end + + defp do_decode(data) do + data + |> decode_property() + |> case do + {nil, <<>>} -> [] + {decoded, rest} -> [decoded] ++ do_decode(rest) + end + end + + defp decode_property(<<>>) do + {nil, <<>>} + end + + defp decode_property(<<0x01, value::8, rest::binary>>) when value in [0, 1] do + {{:payload_format_indicator, value}, rest} + end + + defp decode_property(<<0x02, value::integer-size(32), rest::binary>>) do + {{:message_expiry_interval, value}, rest} + end + + defp decode_property(<<0x03, length::integer-size(16), rest::binary>>) do + <> = rest + {{:content_type, value}, rest} + end + + defp decode_property(<<0x08, length::integer-size(16), rest::binary>>) do + <> = rest + {{:response_topic, value}, rest} + end + + defp decode_property(<<0x09, length::integer-size(16), rest::binary>>) do + <> = rest + {{:correlation_data, value}, rest} + end + + defp decode_property(<<0x0B, rest::binary>>) do + case rest do + <<0::1, value::integer-size(7), rest::binary>> -> + {{:subscription_identifier, value}, rest} + + <<1::1, a::7, 0::1, b::7, rest::binary>> -> + <> = <> + {{:subscription_identifier, value}, rest} + + <<1::1, a::7, 1::1, b::7, 0::1, c::7, rest::binary>> -> + <> = <> + {{:subscription_identifier, value}, rest} + + <<1::1, a::7, 1::1, b::7, 1::1, c::7, 0::1, d::7, rest::binary>> -> + <> = <> + {{:subscription_identifier, value}, rest} + end + end + + defp decode_property(<<0x11, value::integer-size(32), rest::binary>>) do + {{:session_expiry_interval, value}, rest} + end + + defp decode_property(<<0x12, length::integer-size(16), rest::binary>>) do + <> = rest + {{:assigned_client_identifier, value}, rest} + end + + defp decode_property(<<0x13, value::integer-size(16), rest::binary>>) do + {{:server_keep_alive, value}, rest} + end + + defp decode_property(<<0x15, length::integer-size(16), rest::binary>>) do + <> = rest + {{:authentication_method, value}, rest} + end + + defp decode_property(<<0x16, length::integer-size(16), rest::binary>>) do + <> = rest + {{:authentication_data, value}, rest} + end + + defp decode_property(<<0x17, value::8, rest::binary>>) when value in [0, 1] do + {{:request_problem_information, value == 1}, rest} + end + + defp decode_property(<<0x18, value::integer-size(32), rest::binary>>) do + {{:will_delay_interval, value}, rest} + end + + defp decode_property(<<0x19, value::8, rest::binary>>) when value in [0, 1] do + {{:request_response_information, value == 1}, rest} + end + + defp decode_property(<<0x1A, length::integer-size(16), rest::binary>>) do + <> = rest + {{:response_information, value}, rest} + end + + defp decode_property(<<0x1C, length::integer-size(16), rest::binary>>) do + <> = rest + {{:server_reference, value}, rest} + end + + defp decode_property(<<0x1F, length::integer-size(16), rest::binary>>) do + <> = rest + {{:reason_string, value}, rest} + end + + defp decode_property(<<0x21, value::integer-size(16), rest::binary>>) do + {{:receive_maximum, value}, rest} + end + + defp decode_property(<<0x22, value::integer-size(16), rest::binary>>) do + {{:topic_alias_maximum, value}, rest} + end + + defp decode_property(<<0x23, value::integer-size(16), rest::binary>>) do + {{:topic_alias, value}, rest} + end + + defp decode_property(<<0x24, value::8, rest::binary>>) when value in [0, 1] do + {{:maximum_qos, value}, rest} + end + + defp decode_property(<<0x25, value::8, rest::binary>>) when value in [0, 1] do + {{:retain_available, value == 1}, rest} + end + + defp decode_property(<<0x26, rest::binary>>) do + <> = rest + <> = rest + <> = rest + <> = rest + {{:user_property, {key, value}}, rest} + end + + defp decode_property(<<0x27, value::integer-size(32), rest::binary>>) do + {{:maximum_packet_size, value}, rest} + end + + defp decode_property(<<0x28, value::8, rest::binary>>) do + {{:wildcard_subscription_available, value == 1}, rest} + end + + defp decode_property(<<0x29, value::8, rest::binary>>) do + {{:subscription_identifiers_available, value == 1}, rest} + end + + defp decode_property(<<0x2A, value::8, rest::binary>>) do + {{:shared_subscription_available, value == 1}, rest} + end +end diff --git a/lib/tortoise/package/puback.ex b/lib/tortoise/package/puback.ex index 77a11f7e..7b79386e 100644 --- a/lib/tortoise/package/puback.ex +++ b/lib/tortoise/package/puback.ex @@ -3,27 +3,209 @@ defmodule Tortoise.Package.Puback do @opcode 4 + # @allowed_properties [:reason_string, :user_property] + alias Tortoise.Package + @type reason :: :success | {:refused, refusal_reasons()} + @type refusal_reasons :: + :no_matching_subscribers + | :unspecified_error + | :implementation_specific_error + | :not_authorized + | :topic_name_invalid + | :packet_identifier_in_use + | :quota_exceeded + | :payload_format_invalid + @opaque t :: %__MODULE__{ __META__: Package.Meta.t(), - identifier: Tortoise.package_identifier() + identifier: Tortoise.package_identifier(), + reason: reason(), + properties: [{:reason_string, String.t()}, {:user_property, {String.t(), String.t()}}] } @enforce_keys [:identifier] defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0b0000}, - identifier: nil + identifier: nil, + reason: :success, + properties: [] + + @spec decode(binary(), opts :: Keyword.t()) :: t + def decode(<<@opcode::4, 0::4, 2, identifier::big-integer-size(16)>>, _opts) do + %__MODULE__{ + identifier: identifier, + reason: :success, + properties: [] + } + end - @spec decode(<<_::32>>) :: t - def decode(<<@opcode::4, 0::4, 2, identifier::big-integer-size(16)>>) - when identifier in 0x0001..0xFFFF do - %__MODULE__{identifier: identifier} + def decode(<<@opcode::4, 0::4, variable_header::binary>>, _opts) do + <> = + Package.drop_length_prefix(variable_header) + + %__MODULE__{ + identifier: identifier, + reason: coerce_reason_code(reason_code), + properties: Package.Properties.decode(properties) + } + end + + defp coerce_reason_code(reason_code) do + case reason_code do + 0x00 -> :success + 0x10 -> {:refused, :no_matching_subscribers} + 0x80 -> {:refused, :unspecified_error} + 0x83 -> {:refused, :implementation_specific_error} + 0x87 -> {:refused, :not_authorized} + 0x90 -> {:refused, :topic_name_invalid} + 0x91 -> {:refused, :packet_identifier_in_use} + 0x97 -> {:refused, :quota_exceeded} + 0x99 -> {:refused, :payload_format_invalid} + end end # Protocols ---------------------------------------------------------- defimpl Tortoise.Encodable do - def encode(%Package.Puback{identifier: identifier} = t) + def encode( + %Package.Puback{ + identifier: identifier, + reason: :success, + properties: [] + } = t, + _opts + ) when identifier in 0x0001..0xFFFF do + # The Reason Code and Property Length can be omitted if the + # Reason Code is 0x00 (Success) and there are no Properties [Package.Meta.encode(t.__META__), <<2, identifier::big-integer-size(16)>>] end + + def encode(%Package.Puback{identifier: identifier} = t, _opts) + when identifier in 0x0001..0xFFFF do + [ + Package.Meta.encode(t.__META__), + Package.variable_length_encode([ + <>, + Package.Properties.encode(t.properties) + ]) + ] + end + + defp to_reason_code(:success), do: 0x00 + + defp to_reason_code({:refused, reason}) do + case reason do + :no_matching_subscribers -> 0x10 + :unspecified_error -> 0x80 + :implementation_specific_error -> 0x83 + :not_authorized -> 0x87 + :topic_name_invalid -> 0x90 + :packet_identifier_in_use -> 0x91 + :quota_exceeded -> 0x97 + :payload_format_invalid -> 0x99 + end + end + end + + if Code.ensure_loaded?(StreamData) do + defimpl Tortoise.Generatable do + import StreamData + + def generate(%type{__META__: _meta} = package) do + values = package |> Map.from_struct() + + fixed_list(Enum.map(values, &constant(&1))) + |> bind(&gen_identifier/1) + |> bind(&gen_reason/1) + |> bind(&gen_properties/1) + |> bind(fn data -> + fixed_map([ + {:__struct__, type} + | for({k, v} <- data, do: {k, constant(v)}) + ]) + end) + end + + defp gen_identifier(values) do + case Keyword.pop(values, :identifier) do + {nil, values} -> + fixed_list([ + {constant(:identifier), integer(1..0xFFFF)} + | Enum.map(values, &constant(&1)) + ]) + + {id, _} when is_integer(id) and id in 1..0xFFFF -> + constant(values) + end + end + + @refusals [ + :no_matching_subscribers, + :unspecified_error, + :implementation_specific_error, + :not_authorized, + :topic_name_invalid, + :packet_identifier_in_use, + :quota_exceeded, + :payload_format_invalid + ] + + defp gen_reason(values) do + case Keyword.pop(values, :reason) do + {nil, values} -> + fixed_list([ + { + constant(:reason), + StreamData.frequency([ + {60, constant(:success)}, + {40, tuple({constant(:refused), one_of(@refusals)})} + ]) + } + | Enum.map(values, &constant(&1)) + ]) + + {{:refused, nil}, values} -> + fixed_list([ + {:reason, tuple({constant(:refused), one_of(@refusals)})} + | Enum.map(values, &constant(&1)) + ]) + + {:success, _} -> + constant(values) + + {{:refused, refusal_reason}, _} when refusal_reason in @refusals -> + constant(values) + end + end + + defp gen_properties(values) do + case Keyword.pop(values, :properties) do + {nil, values} -> + properties = + uniq_list_of( + one_of([ + # here we allow stings with a byte size of zero; don't + # know if that is a problem according to the spec. Let's + # handle that situation just in case: + {constant(:user_property), {string(:printable), string(:printable)}}, + {constant(:reason_string), string(:printable)} + ]), + uniq_fun: &uniq/1, + max_length: 5 + ) + + fixed_list([ + {constant(:properties), properties} + | Enum.map(values, &constant(&1)) + ]) + + {_passthrough, _} -> + constant(values) + end + end + + defp uniq({:user_property, _v}), do: :crypto.strong_rand_bytes(2) + defp uniq({k, _v}), do: k + end end end diff --git a/lib/tortoise/package/pubcomp.ex b/lib/tortoise/package/pubcomp.ex index 6cc8ea7a..82905d88 100644 --- a/lib/tortoise/package/pubcomp.ex +++ b/lib/tortoise/package/pubcomp.ex @@ -3,26 +3,170 @@ defmodule Tortoise.Package.Pubcomp do @opcode 7 + # @allowed_properties [:reason_string, :user_property] + alias Tortoise.Package + @type reason :: :success | {:refused, refusal_reasons()} + @type refusal_reasons :: :packet_identifier_not_found + @opaque t :: %__MODULE__{ __META__: Package.Meta.t(), - identifier: Tortoise.package_identifier() + identifier: Tortoise.package_identifier(), + reason: reason(), + properties: [{:reason_string, String.t()}, {:user_property, {String.t(), String.t()}}] } defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0}, - identifier: nil + identifier: nil, + reason: :success, + properties: [] - @spec decode(<<_::32>>) :: t - def decode(<<@opcode::4, 0::4, 2, identifier::big-integer-size(16)>>) + @spec decode(binary(), opts :: Keyword.t()) :: t + def decode(<<@opcode::4, 0::4, 2, identifier::big-integer-size(16)>>, _opts) when identifier in 0x0001..0xFFFF do %__MODULE__{identifier: identifier} end + def decode(<<@opcode::4, 0::4, variable_header::binary>>, _opts) do + <> = + Package.drop_length_prefix(variable_header) + + %__MODULE__{ + identifier: identifier, + reason: coerce_reason_code(reason_code), + properties: Package.Properties.decode(properties) + } + end + + defp coerce_reason_code(reason_code) do + case reason_code do + 0x00 -> :success + 0x92 -> {:refused, :packet_identifier_not_found} + end + end + # Protocols ---------------------------------------------------------- defimpl Tortoise.Encodable do - def encode(%Package.Pubcomp{identifier: identifier} = t) + def encode( + %Package.Pubcomp{ + identifier: identifier, + reason: :success, + properties: [] + } = t, + _opts + ) when identifier in 0x0001..0xFFFF do [Package.Meta.encode(t.__META__), <<2, t.identifier::big-integer-size(16)>>] end + + def encode(%Package.Pubcomp{identifier: identifier} = t, _opts) + when identifier in 0x0001..0xFFFF do + [ + Package.Meta.encode(t.__META__), + Package.variable_length_encode([ + <>, + Package.Properties.encode(t.properties) + ]) + ] + end + + defp to_reason_code(:success), do: 0x00 + defp to_reason_code({:refused, :packet_identifier_not_found}), do: 0x92 + end + + if Code.ensure_loaded?(StreamData) do + defimpl Tortoise.Generatable do + import StreamData + + def generate(%type{__META__: _meta} = package) do + values = package |> Map.from_struct() + + fixed_list(Enum.map(values, &constant(&1))) + |> bind(&gen_identifier/1) + |> bind(&gen_reason/1) + |> bind(&gen_properties/1) + |> bind(fn data -> + fixed_map([ + {:__struct__, type} + | for({k, v} <- data, do: {k, constant(v)}) + ]) + end) + end + + defp gen_identifier(values) do + case Keyword.pop(values, :identifier) do + {nil, values} -> + fixed_list([ + {constant(:identifier), integer(1..0xFFFF)} + | Enum.map(values, &constant(&1)) + ]) + + {id, _} when is_integer(id) and id in 1..0xFFFF -> + constant(values) + end + end + + # Might be overkill, but at least we are prepared for more + # refusal reasons in the future should there be more refusals in + # a future protocol version + @refusals [:packet_identifier_not_found] + + defp gen_reason(values) do + case Keyword.pop(values, :reason) do + {nil, values} -> + fixed_list([ + { + constant(:reason), + StreamData.frequency([ + {60, constant(:success)}, + {40, tuple({constant(:refused), one_of(@refusals)})} + ]) + } + | Enum.map(values, &constant(&1)) + ]) + + {{:refused, nil}, values} -> + fixed_list([ + {:reason, tuple({constant(:refused), one_of(@refusals)})} + | Enum.map(values, &constant(&1)) + ]) + + {:success, _} -> + constant(values) + + {{:refused, refusal_reason}, _} when refusal_reason in @refusals -> + constant(values) + end + end + + defp gen_properties(values) do + case Keyword.pop(values, :properties) do + {nil, values} -> + properties = + uniq_list_of( + one_of([ + # here we allow stings with a byte size of zero; don't + # know if that is a problem according to the spec. Let's + # handle that situation just in case: + {constant(:user_property), {string(:printable), string(:printable)}}, + {constant(:reason_string), string(:printable)} + ]), + uniq_fun: &uniq/1, + max_length: 5 + ) + + fixed_list([ + {constant(:properties), properties} + | Enum.map(values, &constant(&1)) + ]) + + {_passthrough, _} -> + constant(values) + end + end + + defp uniq({:user_property, _v}), do: :crypto.strong_rand_bytes(2) + defp uniq({k, _v}), do: k + end end end diff --git a/lib/tortoise/package/publish.ex b/lib/tortoise/package/publish.ex index c31f4928..4a749304 100644 --- a/lib/tortoise/package/publish.ex +++ b/lib/tortoise/package/publish.ex @@ -3,6 +3,17 @@ defmodule Tortoise.Package.Publish do @opcode 3 + @allowed_properties [ + :payload_format_indicator, + :message_expiry_interval, + :topic_alias, + :response_topic, + :correlation_data, + :user_property, + :subscription_identifier, + :content_type + ] + alias Tortoise.Package @type t :: %__MODULE__{ @@ -12,7 +23,8 @@ defmodule Tortoise.Package.Publish do payload: Tortoise.payload(), identifier: Tortoise.package_identifier(), dup: boolean(), - retain: boolean() + retain: boolean(), + properties: [{any(), any()}] } defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0}, identifier: nil, @@ -20,37 +32,41 @@ defmodule Tortoise.Package.Publish do payload: nil, qos: 0, dup: false, - retain: false + retain: false, + properties: [] - @spec decode(binary()) :: t - def decode(<<@opcode::4, 0::1, 0::2, retain::1, length_prefixed_payload::binary>>) do + @spec decode(binary(), opts :: Keyword.t()) :: t + def decode(<<@opcode::4, 0::1, 0::2, retain::1, length_prefixed_payload::binary>>, _opts) do payload = drop_length_prefix(length_prefixed_payload) - {topic, payload} = decode_message(payload) + {topic, properties, payload} = decode_message(payload) - %__MODULE__{ + Package.Meta.infer(%__MODULE__{ qos: 0, identifier: nil, dup: false, retain: retain == 1, topic: topic, - payload: payload - } + payload: payload, + properties: properties + }) end def decode( - <<@opcode::4, dup::1, qos::integer-size(2), retain::1, length_prefixed_payload::binary>> + <<@opcode::4, dup::1, qos::integer-size(2), retain::1, length_prefixed_payload::binary>>, + _opts ) do payload = drop_length_prefix(length_prefixed_payload) - {topic, identifier, payload} = decode_message_with_id(payload) + {topic, identifier, properties, payload} = decode_message_with_id(payload) - %__MODULE__{ + Package.Meta.infer(%__MODULE__{ qos: qos, identifier: identifier, dup: dup == 1, retain: retain == 1, topic: topic, - payload: payload - } + payload: payload, + properties: properties + }) end defp drop_length_prefix(payload) do @@ -62,14 +78,34 @@ defmodule Tortoise.Package.Publish do end end - defp decode_message(<>) do - <> = msg - {topic, nullify(payload)} + defp decode_message(<>) do + <> = package + {properties, payload} = Package.parse_variable_length(rest) + properties = Package.Properties.decode(properties) + + case Keyword.split(properties, @allowed_properties) do + {^properties, []} -> + {topic, properties, nullify(payload)} + + {_, _violations} -> + # todo ! + {topic, properties, nullify(payload)} + end end - defp decode_message_with_id(<>) do - <> = msg - {topic, identifier, nullify(payload)} + defp decode_message_with_id(<>) do + <> = package + {properties, payload} = Package.parse_variable_length(rest) + properties = Package.Properties.decode(properties) + + case Keyword.split(properties, @allowed_properties) do + {^properties, []} -> + {topic, identifier, properties, nullify(payload)} + + {_, _violations} -> + # todo ! + {topic, identifier, properties, nullify(payload)} + end end defp nullify(""), do: nil @@ -77,23 +113,25 @@ defmodule Tortoise.Package.Publish do # Protocols ---------------------------------------------------------- defimpl Tortoise.Encodable do - def encode(%Package.Publish{identifier: nil, qos: 0} = t) do + def encode(%Package.Publish{identifier: nil, qos: 0} = t, _opts) do [ Package.Meta.encode(%{t.__META__ | flags: encode_flags(t)}), Package.variable_length_encode([ Package.length_encode(t.topic), + Package.Properties.encode(t.properties), encode_payload(t) ]) ] end - def encode(%Package.Publish{identifier: identifier, qos: qos} = t) + def encode(%Package.Publish{identifier: identifier, qos: qos} = t, _opts) when identifier in 0x0001..0xFFFF and qos in 1..2 do [ Package.Meta.encode(%{t.__META__ | flags: encode_flags(t)}), Package.variable_length_encode([ Package.length_encode(t.topic), <>, + Package.Properties.encode(t.properties), encode_payload(t) ]) ] @@ -111,4 +149,201 @@ defmodule Tortoise.Package.Publish do defp flag(f) when f in [0, nil, false], do: 0 defp flag(_), do: 1 end + + if Code.ensure_loaded?(StreamData) do + defimpl Tortoise.Generatable do + import StreamData + + alias Tortoise.Generatable.Topic + + def generate(%type{__META__: _meta} = package) do + values = package |> Map.from_struct() + + fixed_list(Enum.map(values, &constant(&1))) + |> bind(&gen_topic/1) + |> bind(&gen_qos/1) + |> bind(&gen_retain/1) + |> bind(&gen_payload/1) + |> bind(&gen_identifier/1) + |> bind(&gen_dup/1) + |> bind(&gen_properties/1) + |> bind(&update_meta_flags/1) + |> bind(fn data -> + fixed_map([ + {:__struct__, type} + | for({k, v} <- data, do: {k, constant(v)}) + ]) + end) + end + + defp update_meta_flags(values) do + {meta, values} = Keyword.pop(values, :__META__) + + qos = Keyword.get(values, :qos) + retain = Keyword.get(values, :retain) + dup = Keyword.get(values, :dup) + + <> = <> + + fixed_list([ + {:__META__, constant(%{meta | flags: flags})} + | Enum.map(values, &constant(&1)) + ]) + end + + defp flag(true), do: 1 + defp flag(false), do: 0 + + defp gen_qos(values) do + case Keyword.pop(values, :qos) do + {nil, values} -> + fixed_list([ + {constant(:qos), integer(0..2)} + | Enum.map(values, &constant(&1)) + ]) + + {qos, _} when is_integer(qos) and qos in 0..2 -> + constant(values) + end + end + + defp gen_identifier(values) do + qos = Keyword.get(values, :qos) + + case Keyword.pop(values, :identifier) do + {nil, values} when qos > 0 -> + fixed_list([ + {:identifier, integer(1..0xFFFF)} + | Enum.map(values, &constant(&1)) + ]) + + {nil, values} when qos == 0 -> + fixed_list([ + {:identifier, nil} + | Enum.map(values, &constant(&1)) + ]) + + {id, _} when qos > 0 and is_integer(id) and id in 1..0xFFFF -> + constant(values) + + {%StreamData{} = generator, values} -> + fixed_list([ + {:identifier, + bind(generator, fn + id when is_integer(id) and id in 1..0xFFFF -> + constant(id) + + nil -> + nil + + _otherwise -> + raise ArgumentError, + """ + User specified identifier generator should return a nil or an integer between 1 and 65535 + """ + end)} + | Enum.map(values, &constant(&1)) + ]) + end + end + + defp gen_topic(values) do + case Keyword.pop(values, :topic) do + {nil, values} -> + bind(Topic.gen_topic(), fn topic_levels -> + fixed_list([ + {:topic, constant(Enum.join(topic_levels, "/"))} + | Enum.map(values, &constant(&1)) + ]) + end) + + {<<_::binary>>, _values} -> + constant(values) + + # TODO support a user specified generator for topic + end + end + + defp gen_retain(values) do + case Keyword.pop(values, :retain) do + {nil, values} -> + fixed_list([{:retain, boolean()} | Enum.map(values, &constant(&1))]) + + {retain, _} when is_boolean(retain) -> + constant(values) + end + end + + defp gen_payload(values) do + case Keyword.pop(values, :payload) do + {nil, values} -> + fixed_list([ + {:payload, frequency([{1, nil}, {5, binary(min_length: 1)}])} + | Enum.map(values, &constant(&1)) + ]) + + {payload, _} when is_binary(payload) -> + constant(values) + end + end + + defp gen_dup(values) do + qos = Keyword.get(values, :qos) + + case Keyword.pop(values, :dup) do + {nil, values} when qos > 0 -> + fixed_list([{:dup, boolean()} | Enum.map(values, &constant(&1))]) + + {nil, values} when qos == 0 -> + fixed_list([{:dup, constant(false)} | Enum.map(values, &constant(&1))]) + + {dup, _} when is_boolean(dup) -> + constant(values) + end + end + + defp gen_properties(values) do + case Keyword.pop(values, :properties) do + {nil, values} -> + properties = + uniq_list_of( + frequency([ + # here we allow stings with a byte size of zero; don't + # know if that is a problem according to the spec. Let's + # handle that situation just in case: + {8, {:user_property, {string(:printable), string(:printable)}}}, + {1, {:payload_format_indicator, integer(0..1)}}, + {1, {:message_expiry_interval, integer(0..0xFFFFFFFF)}}, + {1, {:topic_alias, integer(1..0xFFFF)}}, + {1, {:response_topic, bind(Topic.gen_topic(), &constant(Enum.join(&1, "/")))}}, + {1, {:correlation_data, binary()}}, + {1, {:subscription_identifier, integer(1..268_435_455)}}, + {1, {:content_type, string(:printable)}} + ]), + uniq_fun: &uniq/1, + max_length: 10 + ) + + fixed_list([ + {constant(:properties), properties} + | Enum.map(values, &constant(&1)) + ]) + + {%StreamData{} = generator, values} -> + bind(generator, fn properties when is_list(properties) -> + fixed_list([ + {constant(:properties), constant(properties)} + | Enum.map(values, &constant(&1)) + ]) + end) + + {_passthrough, _} -> + constant(values) + end + end + + defp uniq({:user_property, _v}), do: :crypto.strong_rand_bytes(2) + defp uniq({k, _v}), do: k + end + end end diff --git a/lib/tortoise/package/pubrec.ex b/lib/tortoise/package/pubrec.ex index 5a011630..a5c13564 100644 --- a/lib/tortoise/package/pubrec.ex +++ b/lib/tortoise/package/pubrec.ex @@ -3,26 +3,205 @@ defmodule Tortoise.Package.Pubrec do @opcode 5 + # @allowed_properties [:reason_string, :user_property] + alias Tortoise.Package + @type reason :: :success | {:refused, refusal_reasons()} + @type refusal_reasons :: + :no_matching_subscribers + | :unspecified_error + | :implementation_specific_error + | :not_authorized + | :topic_name_invalid + | :packet_identifier_in_use + | :quota_exceeded + | :payload_format_invalid + @opaque t :: %__MODULE__{ __META__: Package.Meta.t(), - identifier: Tortoise.package_identifier() + identifier: Tortoise.package_identifier(), + reason: reason(), + properties: [{:reason_string, String.t()}, {:user_property, {String.t(), String.t()}}] } - defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0b000}, - identifier: nil + defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0b0000}, + identifier: nil, + reason: :success, + properties: [] - @spec decode(<<_::32>>) :: t - def decode(<<@opcode::4, 0::4, 2, identifier::big-integer-size(16)>>) + @spec decode(binary(), opts :: Keyword.t()) :: t + def decode(<<@opcode::4, 0::4, 2, identifier::big-integer-size(16)>>, _opts) when identifier in 0x0001..0xFFFF do - %__MODULE__{identifier: identifier} + %__MODULE__{identifier: identifier, reason: :success, properties: []} + end + + def decode(<<@opcode::4, 0::4, variable_header::binary>>, _opts) do + <> = + Package.drop_length_prefix(variable_header) + + %__MODULE__{ + identifier: identifier, + reason: coerce_reason_code(reason_code), + properties: Package.Properties.decode(properties) + } + end + + defp coerce_reason_code(reason_code) do + case reason_code do + 0x00 -> :success + 0x10 -> {:refused, :no_matching_subscribers} + 0x80 -> {:refused, :unspecified_error} + 0x83 -> {:refused, :implementation_specific_error} + 0x87 -> {:refused, :not_authorized} + 0x90 -> {:refused, :topic_name_invalid} + 0x91 -> {:refused, :packet_identifier_in_use} + 0x97 -> {:refused, :quota_exceeded} + 0x99 -> {:refused, :payload_format_invalid} + end end # Protocols ---------------------------------------------------------- defimpl Tortoise.Encodable do - def encode(%Package.Pubrec{identifier: identifier} = t) + def encode( + %Package.Pubrec{ + identifier: identifier, + reason: :success, + properties: [] + } = t, + _opts + ) when identifier in 0x0001..0xFFFF do - [Package.Meta.encode(t.__META__), <<2, t.identifier::big-integer-size(16)>>] + # The Reason Code and Property Length can be omitted if the + # Reason Code is 0x00 (Success) and there are no Properties + [Package.Meta.encode(t.__META__), <<2, identifier::big-integer-size(16)>>] + end + + def encode(%Package.Pubrec{identifier: identifier} = t, _opts) + when identifier in 0x0001..0xFFFF do + [ + Package.Meta.encode(t.__META__), + Package.variable_length_encode([ + <>, + Package.Properties.encode(t.properties) + ]) + ] + end + + defp to_reason_code(:success), do: 0x00 + + defp to_reason_code({:refused, reason}) do + case reason do + :no_matching_subscribers -> 0x10 + :unspecified_error -> 0x80 + :implementation_specific_error -> 0x83 + :not_authorized -> 0x87 + :topic_name_invalid -> 0x90 + :packet_identifier_in_use -> 0x91 + :quota_exceeded -> 0x97 + :payload_format_invalid -> 0x99 + end + end + end + + if Code.ensure_loaded?(StreamData) do + defimpl Tortoise.Generatable do + import StreamData + + def generate(%type{__META__: _meta} = package) do + values = package |> Map.from_struct() + + fixed_list(Enum.map(values, &constant(&1))) + |> bind(&gen_identifier/1) + |> bind(&gen_reason/1) + |> bind(&gen_properties/1) + |> bind(fn data -> + fixed_map([ + {:__struct__, type} + | for({k, v} <- data, do: {k, constant(v)}) + ]) + end) + end + + defp gen_identifier(values) do + case Keyword.pop(values, :identifier) do + {nil, values} -> + fixed_list([ + {constant(:identifier), integer(1..0xFFFF)} + | Enum.map(values, &constant(&1)) + ]) + + {id, _} when is_integer(id) and id in 1..0xFFFF -> + constant(values) + end + end + + @refusals [ + :no_matching_subscribers, + :unspecified_error, + :implementation_specific_error, + :not_authorized, + :topic_name_invalid, + :packet_identifier_in_use, + :quota_exceeded, + :payload_format_invalid + ] + + defp gen_reason(values) do + case Keyword.pop(values, :reason) do + {nil, values} -> + fixed_list([ + { + constant(:reason), + StreamData.frequency([ + {60, constant(:success)}, + {40, tuple({constant(:refused), one_of(@refusals)})} + ]) + } + | Enum.map(values, &constant(&1)) + ]) + + {{:refused, nil}, values} -> + fixed_list([ + {:reason, tuple({constant(:refused), one_of(@refusals)})} + | Enum.map(values, &constant(&1)) + ]) + + {:success, _} -> + constant(values) + + {{:refused, refusal_reason}, _} when refusal_reason in @refusals -> + constant(values) + end + end + + defp gen_properties(values) do + case Keyword.pop(values, :properties) do + {nil, values} -> + properties = + uniq_list_of( + one_of([ + # here we allow stings with a byte size of zero; don't + # know if that is a problem according to the spec. Let's + # handle that situation just in case: + {constant(:user_property), {string(:printable), string(:printable)}}, + {constant(:reason_string), string(:printable)} + ]), + uniq_fun: &uniq/1, + max_length: 5 + ) + + fixed_list([ + {constant(:properties), properties} + | Enum.map(values, &constant(&1)) + ]) + + {_passthrough, _} -> + constant(values) + end + end + + defp uniq({:user_property, _v}), do: :crypto.strong_rand_bytes(2) + defp uniq({k, _v}), do: k end end end diff --git a/lib/tortoise/package/pubrel.ex b/lib/tortoise/package/pubrel.ex index 72a05fcc..9cb20b3c 100644 --- a/lib/tortoise/package/pubrel.ex +++ b/lib/tortoise/package/pubrel.ex @@ -3,27 +3,173 @@ defmodule Tortoise.Package.Pubrel do @opcode 6 + # @allowed_properties [:reason_string, :user_property] + alias Tortoise.Package + @type reason :: :success | {:refused, refusal_reasons()} + @type refusal_reasons :: :packet_identifier_not_found + @opaque t :: %__MODULE__{ __META__: Package.Meta.t(), - identifier: Tortoise.package_identifier() + identifier: Tortoise.package_identifier(), + reason: reason(), + properties: [{:reason_string, String.t()}, {:user_property, {String.t(), String.t()}}] } @enforce_keys [:identifier] defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0b0010}, - identifier: nil + identifier: nil, + reason: :success, + properties: [] - @spec decode(<<_::32>>) :: t - def decode(<<@opcode::4, 2::4, 2, identifier::big-integer-size(16)>>) + @spec decode(<<_::32>>, opts :: Keyword.t()) :: t + def decode(<<@opcode::4, 2::4, 2, identifier::big-integer-size(16)>>, _opts) when identifier in 0x0001..0xFFFF do %__MODULE__{identifier: identifier} end + def decode(<<@opcode::4, 2::4, variable_header::binary>>, _opts) do + <> = + Package.drop_length_prefix(variable_header) + + %__MODULE__{ + identifier: identifier, + reason: coerce_reason_code(reason_code), + properties: Package.Properties.decode(properties) + } + end + + defp coerce_reason_code(reason_code) do + case reason_code do + 0x00 -> :success + 0x92 -> {:refused, :packet_identifier_not_found} + end + end + # Protocols ---------------------------------------------------------- defimpl Tortoise.Encodable do - def encode(%Package.Pubrel{identifier: identifier} = t) + def encode( + %Package.Pubrel{ + identifier: identifier, + reason: :success, + properties: [] + } = t, + _opts + ) + when identifier in 0x0001..0xFFFF do + # The Reason Code and Property Length can be omitted if the + # Reason Code is 0x00 (Success) and there are no Properties + [Package.Meta.encode(t.__META__), <<2, identifier::big-integer-size(16)>>] + end + + def encode(%Package.Pubrel{identifier: identifier} = t, _opts) when identifier in 0x0001..0xFFFF do - [Package.Meta.encode(t.__META__), <<2, t.identifier::big-integer-size(16)>>] + [ + Package.Meta.encode(t.__META__), + Package.variable_length_encode([ + <>, + Package.Properties.encode(t.properties) + ]) + ] + end + + defp to_reason_code(:success), do: 0x00 + defp to_reason_code({:refused, :packet_identifier_not_found}), do: 0x92 + end + + if Code.ensure_loaded?(StreamData) do + defimpl Tortoise.Generatable do + import StreamData + + def generate(%type{__META__: _meta} = package) do + values = package |> Map.from_struct() + + fixed_list(Enum.map(values, &constant(&1))) + |> bind(&gen_identifier/1) + |> bind(&gen_reason/1) + |> bind(&gen_properties/1) + |> bind(fn data -> + fixed_map([ + {:__struct__, type} + | for({k, v} <- data, do: {k, constant(v)}) + ]) + end) + end + + defp gen_identifier(values) do + case Keyword.pop(values, :identifier) do + {nil, values} -> + fixed_list([ + {constant(:identifier), integer(1..0xFFFF)} + | Enum.map(values, &constant(&1)) + ]) + + {id, _} when is_integer(id) and id in 1..0xFFFF -> + constant(values) + end + end + + # Might be overkill, but at least we are prepared for more + # refusal reasons in the future should there be more refusals in + # a future protocol version + @refusals [:packet_identifier_not_found] + + defp gen_reason(values) do + case Keyword.pop(values, :reason) do + {nil, values} -> + fixed_list([ + { + constant(:reason), + StreamData.frequency([ + {60, constant(:success)}, + {40, tuple({constant(:refused), one_of(@refusals)})} + ]) + } + | Enum.map(values, &constant(&1)) + ]) + + {{:refused, nil}, values} -> + fixed_list([ + {:reason, tuple({constant(:refused), one_of(@refusals)})} + | Enum.map(values, &constant(&1)) + ]) + + {:success, _} -> + constant(values) + + {{:refused, refusal_reason}, _} when refusal_reason in @refusals -> + constant(values) + end + end + + defp gen_properties(values) do + case Keyword.pop(values, :properties) do + {nil, values} -> + properties = + uniq_list_of( + one_of([ + # here we allow stings with a byte size of zero; don't + # know if that is a problem according to the spec. Let's + # handle that situation just in case: + {constant(:user_property), {string(:printable), string(:printable)}}, + {constant(:reason_string), string(:printable)} + ]), + uniq_fun: &uniq/1, + max_length: 5 + ) + + fixed_list([ + {constant(:properties), properties} + | Enum.map(values, &constant(&1)) + ]) + + {_passthrough, _} -> + constant(values) + end + end + + defp uniq({:user_property, _v}), do: :crypto.strong_rand_bytes(2) + defp uniq({k, _v}), do: k end end end diff --git a/lib/tortoise/package/suback.ex b/lib/tortoise/package/suback.ex index b70757a2..083276e3 100644 --- a/lib/tortoise/package/suback.ex +++ b/lib/tortoise/package/suback.ex @@ -3,31 +3,51 @@ defmodule Tortoise.Package.Suback do @opcode 9 + # @allowed_properties [:reason_string, :user_property] + alias Tortoise.Package @type qos :: 0 | 1 | 2 - @type ack_result :: {:ok, qos} | {:error, :access_denied} + @type refusal_reason :: + :unspecified_error + | :implementation_specific_error + | :not_authorized + | :topic_filter_invalid + | :packet_identifier_in_use + | :quota_exceeded + | :shared_subscriptions_not_supported + | :subscription_identifiers_not_supported + | :wildcard_subscriptions_not_supported + + @type ack_result :: {:ok, qos} | {:error, refusal_reason()} @opaque t :: %__MODULE__{ __META__: Package.Meta.t(), identifier: Tortoise.package_identifier(), - acks: [ack_result] + acks: [ack_result], + properties: [{:reason_string, String.t()}, {:user_property, {String.t(), String.t()}}] } @enforce_keys [:identifier] defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0}, identifier: nil, - acks: [] + acks: [], + properties: [] - @spec decode(binary()) :: t - def decode(<<@opcode::4, 0::4, payload::binary>>) do + @spec decode(binary(), opts :: Keyword.t()) :: t + def decode(<<@opcode::4, 0::4, payload::binary>>, _opts) do with payload <- drop_length(payload), - <> <- payload do + <> <- payload, + {properties, acks} = Package.parse_variable_length(rest) do case return_codes_to_list(acks) do [] -> {:error, {:protocol_violation, :empty_subscription_ack}} sub_acks -> - %__MODULE__{identifier: identifier, acks: sub_acks} + %__MODULE__{ + identifier: identifier, + acks: sub_acks, + properties: Package.Properties.decode(properties) + } end end end @@ -43,26 +63,174 @@ defmodule Tortoise.Package.Suback do defp return_codes_to_list(<<>>), do: [] - defp return_codes_to_list(<<0x80::integer, acks::binary>>), - do: [{:error, :access_denied}] ++ return_codes_to_list(acks) + defp return_codes_to_list(<>) do + [ + case code do + maximum_qos when code in 0x00..0x02 -> + {:ok, maximum_qos} + + 0x80 -> + {:error, :unspecified_error} + + 0x83 -> + {:error, :implementation_specific_error} + + 0x87 -> + {:error, :not_authorized} + + 0x8F -> + {:error, :topic_filter_invalid} + + 0x91 -> + {:error, :packet_identifier_in_use} + + 0x97 -> + {:error, :quota_exceeded} - defp return_codes_to_list(<>) when ack in 0x00..0x02, - do: [{:ok, ack}] ++ return_codes_to_list(acks) + 0x9E -> + {:error, :shared_subscriptions_not_supported} + + 0xA1 -> + {:error, :subscription_identifiers_not_supported} + + 0xA2 -> + {:error, :wildcard_subscriptions_not_supported} + end + ] ++ return_codes_to_list(rest) + end # Protocols ---------------------------------------------------------- defimpl Tortoise.Encodable do - def encode(%Package.Suback{identifier: identifier} = t) + def encode(%Package.Suback{identifier: identifier} = t, _opts) when identifier in 0x0001..0xFFFF do [ Package.Meta.encode(t.__META__), Package.variable_length_encode([ <>, + Package.Properties.encode(t.properties), Enum.map(t.acks, &encode_ack/1) ]) ] end defp encode_ack({:ok, qos}) when qos in 0x00..0x02, do: qos - defp encode_ack({:error, _}), do: 0x80 + defp encode_ack({:error, :unspecified_error}), do: 0x80 + defp encode_ack({:error, :implementation_specific_error}), do: 0x83 + defp encode_ack({:error, :not_authorized}), do: 0x87 + defp encode_ack({:error, :topic_filter_invalid}), do: 0x8F + defp encode_ack({:error, :packet_identifier_in_use}), do: 0x91 + defp encode_ack({:error, :quota_exceeded}), do: 0x97 + defp encode_ack({:error, :shared_subscriptions_not_supported}), do: 0x9E + defp encode_ack({:error, :subscription_identifiers_not_supported}), do: 0xA1 + defp encode_ack({:error, :wildcard_subscriptions_not_supported}), do: 0xA2 + end + + if Code.ensure_loaded?(StreamData) do + defimpl Tortoise.Generatable do + import StreamData + + def generate(%type{__META__: _meta} = package) do + values = package |> Map.from_struct() + + fixed_list(Enum.map(values, &constant(&1))) + |> bind(&gen_identifier/1) + |> bind(&gen_acks/1) + |> bind(&gen_properties/1) + |> bind(&fixed_map([{:__struct__, type} | for({k, v} <- &1, do: {k, constant(v)})])) + end + + defp gen_identifier(values) do + case Keyword.pop(values, :identifier) do + {nil, values} -> + fixed_list([ + {constant(:identifier), integer(1..0xFFFF)} + | Enum.map(values, &constant(&1)) + ]) + + {id, _} when is_integer(id) and id in 1..0xFFFF -> + constant(values) + end + end + + @refusals [ + :unspecified_error, + :implementation_specific_error, + :not_authorized, + :topic_filter_invalid, + :packet_identifier_in_use, + :quota_exceeded, + :shared_subscriptions_not_supported, + :subscription_identifiers_not_supported, + :wildcard_subscriptions_not_supported + ] + + defp gen_acks(values) do + # Rule: An empty ack list is not allowed; it is a protocol + # error to not acknowledge or rejct at least one subscription + case Keyword.pop(values, :acks) do + {nil, values} -> + fixed_list([ + {constant(:acks), nonempty(list_of(do_gen_ack()))} + | Enum.map(values, &constant(&1)) + ]) + + # Generate the acks list based on a list containing either + # valid ok/error tuples, or nils, where nils will get + # replaced with an ack generator. This allow us to generate + # lists with a fixed length and with specific spots filled + # with particular values + {[_ | _] = acks, values} -> + fixed_list([ + {constant(:acks), + fixed_list( + Enum.map(acks, fn + nil -> do_gen_ack() + {:ok, n} = value when n in 0..2 -> constant(value) + {:ok, nil} -> {constant(:ok), integer(0..2)} + {:error, e} = value when e in @refusals -> constant(value) + {:error, nil} -> {constant(:error), one_of(@refusals)} + end) + )} + | Enum.map(values, &constant(&1)) + ]) + end + end + + defp do_gen_ack() do + frequency([ + {60, tuple({constant(:ok), integer(0..2)})}, + {40, tuple({constant(:error), one_of(@refusals)})} + ]) + end + + defp gen_properties(values) do + case Keyword.pop(values, :properties) do + {nil, values} -> + properties = + uniq_list_of( + frequency([ + # here we allow stings with a byte size of zero; don't + # know if that is a problem according to the spec. Let's + # handle that situation just in case: + {4, {constant(:user_property), {string(:printable), string(:printable)}}}, + {1, {constant(:reason_string), string(:printable)}} + ]), + uniq_fun: &uniq/1, + max_length: 20 + ) + + fixed_list([ + {constant(:properties), properties} + | Enum.map(values, &constant(&1)) + ]) + + {_passthrough, _} -> + constant(values) + end + end + + defp uniq({:user_property, _v}), do: :crypto.strong_rand_bytes(2) + defp uniq({k, _v}), do: k + end end end diff --git a/lib/tortoise/package/subscribe.ex b/lib/tortoise/package/subscribe.ex index ab326469..54f9a82a 100644 --- a/lib/tortoise/package/subscribe.ex +++ b/lib/tortoise/package/subscribe.ex @@ -3,27 +3,45 @@ defmodule Tortoise.Package.Subscribe do @opcode 8 + # @allowed_properties [:subscription_identifier, :user_property] + alias Tortoise.Package @type qos :: 0 | 1 | 2 - @type topic :: {binary(), qos} + @type topic :: {binary(), topic_opts} + @type topic_opts :: [ + {:qos, qos}, + {:no_local, boolean()}, + {:retain_as_published, boolean()}, + {:retain_handling, 0 | 1 | 2} + ] @type topics :: [topic] @opaque t :: %__MODULE__{ __META__: Package.Meta.t(), identifier: Tortoise.package_identifier(), - topics: topics() + topics: topics(), + properties: [ + {:subscription_identifier, 0x1..0xFFFFFFF}, + {:user_property, {String.t(), String.t()}} + ] } defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0b0010}, identifier: nil, - topics: [] + topics: [], + properties: [] - @spec decode(binary()) :: t - def decode(<<@opcode::4, 0b0010::4, length_prefixed_payload::binary>>) do + @spec decode(binary(), opts :: Keyword.t()) :: t + def decode(<<@opcode::4, 0b0010::4, length_prefixed_payload::binary>>, _opts) do payload = drop_length(length_prefixed_payload) - <> = payload - topic_list = decode_topics(topics) - %__MODULE__{identifier: identifier, topics: topic_list} + <> = payload + {properties, topics} = Package.parse_variable_length(rest) + + %__MODULE__{ + identifier: identifier, + topics: decode_topics(topics), + properties: Package.Properties.decode(properties) + } end defp drop_length(payload) do @@ -38,8 +56,25 @@ defmodule Tortoise.Package.Subscribe do defp decode_topics(<<>>), do: [] defp decode_topics(<>) do - <> = rest - [{topic, return_code}] ++ decode_topics(rest) + << + topic::binary-size(length), + # reserved + 0::2, + retain_handling::2, + retain_as_published::1, + no_local::1, + qos::2, + rest::binary + >> = rest + + opts = [ + qos: qos, + no_local: no_local == 1, + retain_as_published: retain_as_published == 1, + retain_handling: retain_handling + ] + + [{topic, opts}] ++ decode_topics(rest) end # PROTOCOLS ========================================================== @@ -47,25 +82,38 @@ defmodule Tortoise.Package.Subscribe do def encode( %Package.Subscribe{ identifier: identifier, - # a valid subscribe package has at least one topic/qos pair - topics: [{<<_topic_filter::binary>>, qos} | _] - } = t + # a valid subscribe package has at least one topic/opts pair + topics: [{<<_topic_filter::binary>>, opts} | _] + } = t, + _opts ) - when identifier in 0x0001..0xFFFF and qos in 0..2 do + when identifier in 0x0001..0xFFFF and is_list(opts) do [ Package.Meta.encode(t.__META__), Package.variable_length_encode([ <>, + Package.Properties.encode(t.properties), encode_topics(t.topics) ]) ] end defp encode_topics(topics) do - Enum.map(topics, fn {topic, qos} -> - [Package.length_encode(topic), <<0::6, qos::2>>] + Enum.map(topics, fn {topic, opts} -> + qos = Keyword.get(opts, :qos, 0) + no_local = Keyword.get(opts, :no_local, false) + retain_as_published = Keyword.get(opts, :retain_as_published, false) + retain_handling = Keyword.get(opts, :retain_handling, 1) + + [ + Package.length_encode(topic), + <<0::2, retain_handling::2, flag(retain_as_published)::1, flag(no_local)::1, qos::2>> + ] end) end + + defp flag(f) when f in [0, nil, false], do: 0 + defp flag(_), do: 1 end defimpl Enumerable do @@ -73,6 +121,18 @@ defmodule Tortoise.Package.Subscribe do Enumerable.List.reduce(topics, acc, fun) end + def member?(%Package.Subscribe{topics: topics}, {<>, qos}) + when is_integer(qos) do + matcher = fn {current_topic, opts} -> + topic == current_topic && opts[:qos] == qos + end + + case Enum.find(topics, matcher) do + nil -> {:ok, false} + _ -> {:ok, true} + end + end + def member?(%Package.Subscribe{topics: topics}, value) do {:ok, Enum.member?(topics, value)} end @@ -88,23 +148,182 @@ defmodule Tortoise.Package.Subscribe do end defimpl Collectable do - def into(%Package.Subscribe{topics: topics} = source) do - {Enum.into(topics, %{}), + def into(%Package.Subscribe{topics: current_topics} = source) do + {current_topics, fn + acc, {:cont, {<>, opts}} when is_list(opts) -> + List.keystore(acc, topic, 0, {topic, opts}) + acc, {:cont, {<>, qos}} when qos in 0..2 -> - # if a topic filter repeat in the input we will pick the - # biggest one - Map.update(acc, topic, qos, &max(&1, qos)) + List.keystore(acc, topic, 0, {topic, qos: qos}) acc, {:cont, <>} -> - Map.put_new(acc, topic, 0) + List.keystore(acc, topic, 0, {topic, qos: 0}) acc, :done -> - %{source | topics: Map.to_list(acc)} + %{source | topics: acc} _, :halt -> :ok end} end end + + if Code.ensure_loaded?(StreamData) do + defimpl Tortoise.Generatable do + import StreamData + + alias Tortoise.Generatable.Topic + + def generate(%type{__META__: _meta} = package) do + values = package |> Map.from_struct() + + fixed_list(Enum.map(values, &constant(&1))) + |> bind(&gen_identifier/1) + |> bind(&gen_topics/1) + |> bind(&gen_topic_opts/1) + |> bind(&gen_properties/1) + |> bind(&fixed_map([{:__struct__, type} | for({k, v} <- &1, do: {k, constant(v)})])) + end + + defp gen_identifier(values) do + case Keyword.pop(values, :identifier) do + {nil, values} -> + fixed_list([ + {constant(:identifier), integer(1..0xFFFF)} + | Enum.map(values, &constant(&1)) + ]) + + {id, _} when is_integer(id) and id in 1..0xFFFF -> + constant(values) + end + end + + defp gen_topics(values) do + case Keyword.pop(values, :topics) do + {nil, values} -> + fixed_list([ + { + :topics, + list_of( + {gen_topic_filter(), constant(nil)}, + max_length: 5, + min_length: 1 + ) + } + | Enum.map(values, &constant(&1)) + ]) + + {[_ | _] = topics, values} -> + [ + {:topics, + fixed_list( + Enum.map(topics, fn + nil -> + {gen_topic_filter(), constant([])} + + {nil, opts} -> + {gen_topic_filter(), constant(opts)} + + %StreamData{} = generator -> + bind(generator, fn + {<<_::binary>>, opts} = result when is_list(opts) or is_nil(opts) -> + # result seems fine, pass it on + constant(result) + + faulty_return -> + raise ArgumentError, """ + Faulty result from user specified topic generator #{inspect(generator)} + + The generator should return a tuple with two elements, where the first is a binary, and the second is a `nil` or a list. Instead the generator returned: + + #{inspect(faulty_return)} + + """ + end) + + {%StreamData{} = generator, opts} -> + {generator, constant(opts)} + + {<<_::binary>>, _} = otherwise -> + constant(otherwise) + end) + )} + | Enum.map(values, &constant(&1)) + ] + |> fixed_list() + end + end + + defp gen_topic_filter() do + bind(Topic.gen_filter(), &constant(Enum.join(&1, "/"))) + end + + # create random options for the topics in the topic list + defp gen_topic_opts(values) do + # at this point in time we should have a list of topics! + {[{_, _} | _] = topics, values} = Keyword.pop(values, :topics) + + topics_with_opts = + Enum.map(topics, fn {topic_filter, opts} -> + opts = opts || [] + + { + constant(topic_filter), + # notice that while the order of options shouldn't + # matter, it kind of does in the context of the prop + # tests for encoding and decoding the subscribe + # packages, as the keyword lists will get compared for + # equality + fixed_list([ + do_get_opts(opts, :qos, integer(0..2)), + do_get_opts(opts, :no_local, boolean()), + do_get_opts(opts, :retain_as_published, boolean()), + do_get_opts(opts, :retain_handling, integer(0..2)) + ]) + } + end) + |> fixed_list() + + fixed_list([{:topics, topics_with_opts} | Enum.map(values, &constant(&1))]) + end + + defp do_get_opts(opts, key, default) do + generator = + case Keyword.get(opts, key) do + nil -> default + %StreamData{} = generator -> generator + otherwise -> constant(otherwise) + end + + {key, generator} + end + + defp gen_properties(values) do + case Keyword.pop(values, :properties) do + {nil, values} -> + properties = + uniq_list_of( + frequency([ + # here we allow stings with a byte size of zero; don't + # know if that is a problem according to the spec. Let's + # handle that situation just in case: + {4, {:user_property, {string(:printable), string(:printable)}}}, + {1, {:subscription_identifier, integer(1..268_435_455)}} + ]), + uniq_fun: &uniq/1, + max_length: 5 + ) + + fixed_list([{:properties, properties} | Enum.map(values, &constant(&1))]) + + {_passthrough, _} -> + constant(values) + end + end + + defp uniq({:user_property, _v}), do: :crypto.strong_rand_bytes(2) + defp uniq({k, _v}), do: k + end + end end diff --git a/lib/tortoise/package/unsuback.ex b/lib/tortoise/package/unsuback.ex index 2d1b2912..7abc2ae3 100644 --- a/lib/tortoise/package/unsuback.ex +++ b/lib/tortoise/package/unsuback.ex @@ -3,27 +3,209 @@ defmodule Tortoise.Package.Unsuback do @opcode 11 + # @allowed_properties [:reason_string, :user_property] + alias Tortoise.Package + @type refusal :: + :no_subscription_existed + | :unspecified_error + | :implementation_specific_error + | :not_authorized + | :topic_filter_invalid + | :packet_identifier_in_use + + @type result() :: :success | {:error, refusal()} + @opaque t :: %__MODULE__{ __META__: Package.Meta.t(), - identifier: Tortoise.package_identifier() + identifier: Tortoise.package_identifier(), + results: [:success | {:error, refusal}], + properties: [ + {:reason_string, String.t()} + | {:user_property, {String.t(), String.t()}} + ] } @enforce_keys [:identifier] defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0b0000}, - identifier: nil + identifier: nil, + results: [], + properties: [] + + @spec decode(binary(), opts :: Keyword.t()) :: t | {:error, term()} + def decode(<<@opcode::4, 0::4, package::binary>>, _opts) do + with payload <- drop_length(package), + <> <- payload, + {properties, unsubacks} = Package.parse_variable_length(rest) do + case return_codes_to_list(unsubacks) do + [] -> + {:error, {:protocol_violation, :empty_unsubscription_ack}} - @spec decode(<<_::32>>) :: t - def decode(<<@opcode::4, 0::4, 2, identifier::big-integer-size(16)>>) - when identifier in 0x0001..0xFFFF do - %__MODULE__{identifier: identifier} + results -> + %__MODULE__{ + identifier: identifier, + results: results, + properties: Package.Properties.decode(properties) + } + end + end + end + + defp return_codes_to_list(<<>>), do: [] + + defp return_codes_to_list(<>) do + [ + case reason do + 0x00 -> :success + 0x11 -> {:error, :no_subscription_existed} + 0x80 -> {:error, :unspecified_error} + 0x83 -> {:error, :implementation_specific_error} + 0x87 -> {:error, :not_authorized} + 0x8F -> {:error, :topic_filter_invalid} + 0x91 -> {:error, :packet_identifier_in_use} + end + ] ++ return_codes_to_list(rest) + end + + defp drop_length(payload) do + case payload do + <<0::1, _::7, r::binary>> -> r + <<1::1, _::7, 0::1, _::7, r::binary>> -> r + <<1::1, _::7, 1::1, _::7, 0::1, _::7, r::binary>> -> r + <<1::1, _::7, 1::1, _::7, 1::1, _::7, 0::1, _::7, r::binary>> -> r + end end # Protocols ---------------------------------------------------------- defimpl Tortoise.Encodable do - def encode(%Package.Unsuback{identifier: identifier} = t) + def encode(%Package.Unsuback{identifier: identifier} = t, _opts) when identifier in 0x0001..0xFFFF do - [Package.Meta.encode(t.__META__), <<2, identifier::big-integer-size(16)>>] + [ + Package.Meta.encode(t.__META__), + Package.variable_length_encode([ + <>, + Package.Properties.encode(t.properties), + Enum.map(t.results, &encode_result/1) + ]) + ] + end + + defp encode_result(:success), do: 0x00 + + defp encode_result({:error, reason}) do + case reason do + :no_subscription_existed -> 0x11 + :unspecified_error -> 0x80 + :implementation_specific_error -> 0x83 + :not_authorized -> 0x87 + :topic_filter_invalid -> 0x8F + :packet_identifier_in_use -> 0x91 + end + end + end + + if Code.ensure_loaded?(StreamData) do + defimpl Tortoise.Generatable do + import StreamData + + def generate(%type{__META__: _meta} = package) do + values = package |> Map.from_struct() + + fixed_list(Enum.map(values, &constant(&1))) + |> bind(&gen_identifier/1) + |> bind(&gen_results/1) + |> bind(&gen_properties/1) + |> bind(&fixed_map([{:__struct__, type} | for({k, v} <- &1, do: {k, constant(v)})])) + end + + defp gen_identifier(values) do + case Keyword.pop(values, :identifier) do + {nil, values} -> + fixed_list([ + {constant(:identifier), integer(1..0xFFFF)} + | Enum.map(values, &constant(&1)) + ]) + + {id, _} when is_integer(id) and id in 1..0xFFFF -> + constant(values) + end + end + + @refusals [ + :no_subscription_existed, + :unspecified_error, + :implementation_specific_error, + :not_authorized, + :topic_filter_invalid, + :packet_identifier_in_use + ] + + defp gen_results(values) do + # Rule: An empty ack list is not allowed; it is a protocol + # error to not acknowledge or rejct at least one subscription + case Keyword.pop(values, :results) do + {nil, values} -> + fixed_list([ + {constant(:results), nonempty(list_of(do_gen_result()))} + | Enum.map(values, &constant(&1)) + ]) + + # Generate the results list based on a list containing either + # valid success/error tuples, or nils, where nils will get + # replaced with an result generator. This allow us to generate + # lists with a fixed length and with specific spots filled + # with particular values + {[_ | _] = results, values} -> + fixed_list([ + {constant(:results), + fixed_list( + Enum.map(results, fn + nil -> do_gen_result() + :success -> constant(:success) + {:error, e} = value when e in @refusals -> constant(value) + {:error, nil} -> {constant(:error), one_of(@refusals)} + end) + )} + | Enum.map(values, &constant(&1)) + ]) + end + end + + defp do_gen_result() do + frequency([ + {60, constant(:success)}, + {40, tuple({constant(:error), one_of(@refusals)})} + ]) + end + + defp gen_properties(values) do + case Keyword.pop(values, :properties) do + {nil, values} -> + properties = + uniq_list_of( + frequency([ + # here we allow stings with a byte size of zero; don't + # know if that is a problem according to the spec. Let's + # handle that situation just in case: + {4, {constant(:user_property), {string(:printable), string(:printable)}}}, + {1, {constant(:reason_string), string(:printable)}} + ]), + uniq_fun: &uniq/1, + max_length: 20 + ) + + fixed_list([ + {constant(:properties), properties} + | Enum.map(values, &constant(&1)) + ]) + + {_passthrough, _} -> + constant(values) + end + end + + defp uniq({:user_property, _v}), do: :crypto.strong_rand_bytes(2) + defp uniq({k, _v}), do: k end end end diff --git a/lib/tortoise/package/unsubscribe.ex b/lib/tortoise/package/unsubscribe.ex index 58c408ef..6b1a140b 100644 --- a/lib/tortoise/package/unsubscribe.ex +++ b/lib/tortoise/package/unsubscribe.ex @@ -3,6 +3,8 @@ defmodule Tortoise.Package.Unsubscribe do @opcode 10 + # @allowed_properties [:user_property] + alias Tortoise.Package @type topic :: binary() @@ -10,18 +12,26 @@ defmodule Tortoise.Package.Unsubscribe do @opaque t :: %__MODULE__{ __META__: Package.Meta.t(), identifier: Tortoise.package_identifier(), - topics: [topic] + topics: [topic], + properties: [{:user_property, {String.t(), String.t()}}] } defstruct __META__: %Package.Meta{opcode: @opcode, flags: 2}, topics: [], - identifier: nil + identifier: nil, + properties: [] - @spec decode(binary()) :: t - def decode(<<@opcode::4, 0b0010::4, payload::binary>>) do + @spec decode(binary(), opts :: Keyword.t()) :: t + def decode(<<@opcode::4, 0b0010::4, payload::binary>>, _opts) do with payload <- drop_length(payload), - <> <- payload, - topic_list <- decode_topics(topics), - do: %__MODULE__{identifier: identifier, topics: topic_list} + <> <- payload, + {properties, topics} = Package.parse_variable_length(rest), + topic_list <- decode_topics(topics) do + %__MODULE__{ + identifier: identifier, + topics: topic_list, + properties: Package.Properties.decode(properties) + } + end end defp drop_length(payload) do @@ -46,17 +56,121 @@ defmodule Tortoise.Package.Unsubscribe do %Package.Unsubscribe{ identifier: identifier, # a valid unsubscribe package has at least one topic filter - topics: [_topic_filter | _] - } = t + topics: [topic_filter | _] + } = t, + _opts ) - when identifier in 0x0001..0xFFFF do + when identifier in 0x0001..0xFFFF and is_binary(topic_filter) do [ Package.Meta.encode(t.__META__), Package.variable_length_encode([ <>, + Package.Properties.encode(t.properties), Enum.map(t.topics, &Package.length_encode/1) ]) ] end end + + if Code.ensure_loaded?(StreamData) do + defimpl Tortoise.Generatable do + import StreamData + + alias Tortoise.Generatable.Topic + + def generate(%type{__META__: _meta} = package) do + values = package |> Map.from_struct() + + fixed_list(Enum.map(values, &constant(&1))) + |> bind(&gen_identifier/1) + |> bind(&gen_topics/1) + |> bind(&gen_properties/1) + |> bind(&fixed_map([{:__struct__, type} | for({k, v} <- &1, do: {k, constant(v)})])) + end + + defp gen_identifier(values) do + case Keyword.pop(values, :identifier) do + {nil, values} -> + fixed_list([ + {:identifier, integer(1..0xFFFF)} + | Enum.map(values, &constant(&1)) + ]) + + {id, _} when is_integer(id) and id in 1..0xFFFF -> + constant(values) + end + end + + defp gen_topics(values) do + case Keyword.pop(values, :topics) do + {nil, values} -> + fixed_list([ + { + :topics, + list_of(gen_topic_filter(), min_length: 1, max_length: 5) + } + | Enum.map(values, &constant(&1)) + ]) + + {[_ | _] = topics, values} -> + [ + {:topics, + fixed_list( + Enum.map(topics, fn + nil -> + gen_topic_filter() + + %StreamData{} = generator -> + bind(generator, fn + <> when byte_size(topic_filter) > 1 -> + constant(topic_filter) + + faulty_return -> + raise ArgumentError, """ + Faulty result from user specified topic filter generator #{ + inspect(generator) + } + + The generator should return a non-empty binary. Instead the generator returned: + + #{inspect(faulty_return)} + """ + end) + + <> -> + constant(topic_filter) + end) + )} + | Enum.map(values, &constant(&1)) + ] + |> fixed_list() + end + end + + defp gen_topic_filter() do + bind(Topic.gen_filter(), &constant(Enum.join(&1, "/"))) + end + + defp gen_properties(values) do + # user properties are the only valid property for unsubscribe + # packages + case Keyword.pop(values, :properties) do + {nil, values} -> + properties = + list_of( + # here we allow stings with a byte size of zero; don't + # know if that is a problem according to the + # spec. Let's handle that situation just in case: + {:user_property, {string(:printable), string(:printable)}}, + max_length: 5 + ) + + fixed_list([{:properties, properties} | Enum.map(values, &constant(&1))]) + + {_passthrough, _} -> + constant(values) + end + end + end + end end diff --git a/lib/tortoise/pipe.ex b/lib/tortoise/pipe.ex index 221f2eda..09e3d936 100644 --- a/lib/tortoise/pipe.ex +++ b/lib/tortoise/pipe.ex @@ -99,13 +99,14 @@ defmodule Tortoise.Pipe do end end - defp do_publish(%Pipe{client_id: client_id} = pipe, %Package.Publish{qos: qos} = publish) + defp do_publish(%Pipe{client_id: _client_id} = _pipe, %Package.Publish{qos: qos} = _publish) when qos in 1..2 do - case Inflight.track(client_id, {:outgoing, publish}) do - {:ok, ref} -> - updated_pending = [ref | pipe.pending] - %Pipe{pipe | pending: updated_pending} - end + # case Inflight.track(client_id, {:outgoing, publish}) do + # {:ok, ref} -> + # updated_pending = [ref | pipe.pending] + # %Pipe{pipe | pending: updated_pending} + # end + nil end defp refresh(%Pipe{active: true, client_id: client_id} = pipe) do @@ -147,7 +148,7 @@ defmodule Tortoise.Pipe do def await(%Pipe{client_id: client_id, pending: [ref | rest]} = pipe, timeout) do receive do - {{Tortoise, ^client_id}, ^ref, :ok} -> + {{Tortoise, ^client_id}, {Package.Publish, ^ref}, :ok} -> await(%Pipe{pipe | pending: rest}) after timeout -> diff --git a/lib/tortoise/registry.ex b/lib/tortoise/registry.ex index 48b69169..5eaf0b85 100644 --- a/lib/tortoise/registry.ex +++ b/lib/tortoise/registry.ex @@ -25,15 +25,4 @@ defmodule Tortoise.Registry do def put_meta(key, value) do :ok = Registry.put_meta(__MODULE__, key, value) end - - @spec delete_meta(key :: term()) :: :ok | no_return - def delete_meta(key) do - try do - :ets.delete(__MODULE__, key) - :ok - catch - :error, :badarg -> - raise ArgumentError, "unknown registry: #{inspect(__MODULE__)}" - end - end end diff --git a/lib/tortoise/session.ex b/lib/tortoise/session.ex new file mode 100644 index 00000000..bb3bad1a --- /dev/null +++ b/lib/tortoise/session.ex @@ -0,0 +1,94 @@ +defmodule Tortoise.Session do + @moduledoc """ + Keep track of inflight message for a session + """ + + alias __MODULE__ + alias Tortoise.Package + + @enforce_keys [:client_id] + defstruct backend: {Tortoise.Session.Ets, Tortoise.Session}, + client_id: nil + + def child_spec(opts) do + mod = Keyword.fetch!(opts, :backend) + + %{ + id: mod, + start: {mod, :start_link, [opts]}, + type: :worker, + restart: :permanent, + shutdown: 500 + } + end + + @doc """ + + """ + def track( + %Session{} = session, + {:incoming, %Package.Publish{identifier: id, qos: qos, dup: _} = package} + ) + when not is_nil(id) and qos in 1..2 do + {backend, ref} = session.backend + + case backend.create(ref, session, {:incoming, package}) do + {:ok, %Package.Publish{identifier: ^id} = package, session} -> + {{:cont, package}, session} + + {:error, _reason} = error -> + error + end + end + + def track( + %Session{} = session, + {:outgoing, %type{identifier: _hopefully_nil} = package} + ) + when type in [Package.Publish, Package.Subscribe, Package.Unsubscribe] do + {backend, ref} = session.backend + + case backend.create(ref, session, {:outgoing, package}) do + {:ok, %Package.Publish{qos: qos} = package, session} when qos in 1..2 -> + # By passing back the package we can allow the backend to + # monkey with the user defined properties, and set a unique id + {{:cont, package}, session} + + {:ok, %Package.Subscribe{} = package, session} -> + {{:cont, package}, session} + + {:ok, %Package.Unsubscribe{} = package, session} -> + {{:cont, package}, session} + end + end + + @doc """ + + """ + def progress( + %Session{} = session, + {direction, %_type{identifier: id} = package} + ) + when direction in [:incoming, :outgoing] and id in 0x0001..0xFFFF do + {backend, ref} = session.backend + + case backend.update(ref, session, {direction, package}) do + {:ok, package, session} -> + {{:cont, package}, session} + + {:error, :not_found} = error -> + error + end + end + + @doc """ + + """ + def release( + %Session{backend: {backend, ref}} = session, + id + ) + when id in 0x0001..0xFFFF do + {:ok, %Session{}} = backend.release(ref, session, id) + end +end diff --git a/lib/tortoise/session/ets.ex b/lib/tortoise/session/ets.ex new file mode 100644 index 00000000..c243b08a --- /dev/null +++ b/lib/tortoise/session/ets.ex @@ -0,0 +1,112 @@ +defmodule Tortoise.Session.Ets do + use GenServer + + @name Tortoise.Session + + alias Tortoise.{Session, Package} + + # Client API + def start_link(opts) do + name = Keyword.get(opts, :name, @name) + GenServer.start_link(__MODULE__, opts, name: name) + end + + def create(instance \\ @name, session, package) + + def create(instance, session, {:incoming, %Package.Publish{identifier: id} = package}) + when not is_nil(id) do + do_create(instance, session, {:incoming, package}) + end + + def create(instance, session, {:outgoing, %_type{} = package}) do + do_create(instance, session, {:outgoing, package}) + end + + # attempt to create an id if none is present + defp do_create(instance, session, package, attempt \\ 0) + + defp do_create( + instance, + %Session{} = session, + {:outgoing, %type{identifier: nil} = package}, + attempt + ) + when attempt < 10 do + <> = :crypto.strong_rand_bytes(2) + data = {:outgoing, %{package | identifier: id}} + + case do_create(instance, session, data, attempt) do + {:ok, %^type{} = package, session} -> + {:ok, package, session} + + {:error, :non_unique_package_identifier} -> + do_create(instance, session, package, attempt + 1) + end + end + + defp do_create(_, _, {:outgoing, %_type{identifier: nil}}, _attempt) do + {:error, :could_not_create_unique_identifier} + end + + defp do_create( + instance, + %Session{client_id: client_id} = session, + {direction, %{identifier: id} = package}, + _attempt + ) + when direction in [:incoming, :outgoing] and id in 1..0xFFFF do + now = System.monotonic_time() + + case :ets.insert_new(instance, {{client_id, id}, {now, direction, package}}) do + true -> + {:ok, package, session} + + false -> + {:error, :non_unique_package_identifier} + end + end + + def read(instance \\ @name, %Session{client_id: client_id} = session, package_id) do + case :ets.lookup(instance, {client_id, package_id}) do + [{{^client_id, ^package_id}, {_, direction, %_type{identifier: ^package_id} = package}}] -> + {:ok, {direction, package}, session} + + [] -> + {:error, :not_found} + end + end + + def update(instance \\ @name, session, package) + + def update( + instance, + %Session{client_id: client_id} = session, + {direction, %_type{identifier: package_id} = package} + ) do + now = System.monotonic_time() + key = {client_id, package_id} + + case :ets.update_element(instance, key, {2, {now, direction, package}}) do + true -> + {:ok, package, session} + + false -> + {:error, :not_found} + end + end + + def release(instance \\ @name, %Session{client_id: client_id} = session, package_id) do + true = :ets.delete(instance, {client_id, package_id}) + {:ok, session} + end + + # Server callbacks + def init(opts) do + # do as little as possible, making it really hard to crash the + # instance state + name = Keyword.get(opts, :name, @name) + ref = :ets.new(name, [:named_table, :public, {:write_concurrency, true}]) + + {:ok, ref} + end +end diff --git a/lib/tortoise/transmitter_supervisor.ex b/lib/tortoise/transmitter_supervisor.ex new file mode 100644 index 00000000..ebb02b8a --- /dev/null +++ b/lib/tortoise/transmitter_supervisor.ex @@ -0,0 +1,22 @@ +defmodule Tortoise.TransmitterSupervisor do + @moduledoc false + + alias Tortoise.Connection.Receiver + + use DynamicSupervisor + + def start_link(init_arg) do + DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + def start_transmitter(sup \\ __MODULE__, opts) do + opts = Keyword.put(opts, :parent, self()) + spec = {Receiver, Keyword.take(opts, [:transport, :parent])} + DynamicSupervisor.start_child(sup, spec) + end + + @impl true + def init(_init_arg) do + DynamicSupervisor.init(strategy: :one_for_one) + end +end diff --git a/lib/tortoise/transport.ex b/lib/tortoise/transport.ex index be09dc2b..59c124e0 100644 --- a/lib/tortoise/transport.ex +++ b/lib/tortoise/transport.ex @@ -68,4 +68,9 @@ defmodule Tortoise.Transport do @callback shutdown(socket(), :read | :write | :read_write) :: :ok | {:error, atom()} @callback close(socket()) :: :ok + + # todo + @callback format_error(term()) :: term() + + @optional_callbacks format_error: 1 end diff --git a/mix.exs b/mix.exs index d36b3b54..db70f452 100644 --- a/mix.exs +++ b/mix.exs @@ -22,7 +22,8 @@ defmodule Tortoise.MixProject do "coveralls.json": :test, "coveralls.post": :test, docs: :docs - ] + ], + elixirc_paths: elixirc_paths(Mix.env()) ] end @@ -36,7 +37,7 @@ defmodule Tortoise.MixProject do def application do [ extra_applications: [:logger, :ssl], - mod: {Tortoise.App, []} + mod: {Tortoise.Application, []} ] end @@ -45,13 +46,16 @@ defmodule Tortoise.MixProject do [ {:gen_state_machine, "~> 2.0"}, {:dialyxir, "~> 1.0.0-rc.3", only: [:dev], runtime: false}, - {:eqc_ex, "~> 1.4", only: :test}, + {:stream_data, "~> 0.5", only: [:test, :dev]}, {:excoveralls, "~> 0.10", only: :test}, {:ex_doc, "~> 0.19", only: :docs}, {:ct_helper, github: "ninenines/ct_helper", only: :test} ] end + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + defp package() do [ maintainers: ["Martin Gausby"], diff --git a/mix.lock b/mix.lock index ccd4927d..bf5f88d1 100644 --- a/mix.lock +++ b/mix.lock @@ -1,23 +1,24 @@ %{ - "certifi": {:hex, :certifi, "2.3.1", "d0f424232390bf47d82da8478022301c561cf6445b5b5fb6a84d49a9e76d2639", [:rebar3], [{:parse_trans, "3.2.0", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, + "certifi": {:hex, :certifi, "2.3.1", "d0f424232390bf47d82da8478022301c561cf6445b5b5fb6a84d49a9e76d2639", [:rebar3], [{:parse_trans, "3.2.0", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "e12d667d042c11d130594bae2b0097e63836fe8b1e6d6b2cc48f8bb7a2cf7d68"}, "ct_helper": {:git, "https://github.com/ninenines/ct_helper.git", "6cf0748b5ac7bd32f8d338224b843e419b1ea7c0", []}, - "dialyxir": {:hex, :dialyxir, "1.0.0-rc.3", "774306f84973fc3f1e2e8743eeaa5f5d29b117f3916e5de74c075c02f1b8ef55", [:mix], [], "hexpm"}, - "earmark": {:hex, :earmark, "1.2.5", "4d21980d5d2862a2e13ec3c49ad9ad783ffc7ca5769cf6ff891a4553fbaae761", [:mix], [], "hexpm"}, - "eqc_ex": {:hex, :eqc_ex, "1.4.2", "c89322cf8fbd4f9ddcb18141fb162a871afd357c55c8c0198441ce95ffe2e105", [:mix], [], "hexpm"}, - "ex_doc": {:hex, :ex_doc, "0.19.1", "519bb9c19526ca51d326c060cb1778d4a9056b190086a8c6c115828eaccea6cf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.7", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, - "excoveralls": {:hex, :excoveralls, "0.10.0", "a4508bdd408829f38e7b2519f234b7fd5c83846099cda348efcb5291b081200c", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, + "dialyxir": {:hex, :dialyxir, "1.0.0-rc.3", "774306f84973fc3f1e2e8743eeaa5f5d29b117f3916e5de74c075c02f1b8ef55", [:mix], [], "hexpm", "ddde98a783cec47d1b0ffaf5a0d5fbe09e90e1ac2d28452eefa6f72aac072c5a"}, + "earmark": {:hex, :earmark, "1.2.5", "4d21980d5d2862a2e13ec3c49ad9ad783ffc7ca5769cf6ff891a4553fbaae761", [:mix], [], "hexpm", "c57508ddad47dfb8038ca6de1e616e66e9b87313220ac5d9817bc4a4dc2257b9"}, + "eqc_ex": {:hex, :eqc_ex, "1.4.2", "c89322cf8fbd4f9ddcb18141fb162a871afd357c55c8c0198441ce95ffe2e105", [:mix], [], "hexpm", "6547e68351624ca5387df7e3332136b07f1be73c5a429c1b4e40436dcad50f38"}, + "ex_doc": {:hex, :ex_doc, "0.19.1", "519bb9c19526ca51d326c060cb1778d4a9056b190086a8c6c115828eaccea6cf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.7", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "dc87f778d8260da0189a622f62790f6202af72f2f3dee6e78d91a18dd2fcd137"}, + "excoveralls": {:hex, :excoveralls, "0.10.0", "a4508bdd408829f38e7b2519f234b7fd5c83846099cda348efcb5291b081200c", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "74d87b5642251722b94a5bcf493a409c01ebba8962ff79b47365172b11c0280d"}, "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, - "gen_state_machine": {:hex, :gen_state_machine, "2.0.3", "477ea51b466a749ab23a0d6090e9e84073f41f9aa28c7efc40eac18f3d4a9f77", [:mix], [], "hexpm"}, - "hackney": {:hex, :hackney, "1.13.0", "24edc8cd2b28e1c652593833862435c80661834f6c9344e84b6a2255e7aeef03", [:rebar3], [{:certifi, "2.3.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.2", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, - "idna": {:hex, :idna, "5.1.2", "e21cb58a09f0228a9e0b95eaa1217f1bcfc31a1aaa6e1fdf2f53a33f7dbd9494", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, - "jason": {:hex, :jason, "1.1.1", "d3ccb840dfb06f2f90a6d335b536dd074db748b3e7f5b11ab61d239506585eb2", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, + "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm", "5cacd405e72b2609a7e1f891bddb80c53d0b3b7b0036d1648e7382ca108c41c8"}, + "hackney": {:hex, :hackney, "1.13.0", "24edc8cd2b28e1c652593833862435c80661834f6c9344e84b6a2255e7aeef03", [:rebar3], [{:certifi, "2.3.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.2", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "4d605d33dd07ee1b82b105033cccb02379515105fceb1850746591814b00c205"}, + "idna": {:hex, :idna, "5.1.2", "e21cb58a09f0228a9e0b95eaa1217f1bcfc31a1aaa6e1fdf2f53a33f7dbd9494", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "8fddb3aec4692c71647d67de72536254bce9069851754e370a99f2aae69fbdf4"}, + "jason": {:hex, :jason, "1.1.1", "d3ccb840dfb06f2f90a6d335b536dd074db748b3e7f5b11ab61d239506585eb2", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "639645cfac325e34938167b272bae0791fea3a34cf32c29525abf1d323ed4c18"}, "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"}, - "makeup": {:hex, :makeup, "0.5.1", "966c5c2296da272d42f1de178c1d135e432662eca795d6dc12e5e8787514edf7", [:mix], [{:nimble_parsec, "~> 0.2.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.8.0", "1204a2f5b4f181775a0e456154830524cf2207cf4f9112215c05e0b76e4eca8b", [:mix], [{:makeup, "~> 0.5.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 0.2.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, - "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, - "nimble_parsec": {:hex, :nimble_parsec, "0.2.2", "d526b23bdceb04c7ad15b33c57c4526bf5f50aaa70c7c141b4b4624555c68259", [:mix], [], "hexpm"}, - "parse_trans": {:hex, :parse_trans, "3.2.0", "2adfa4daf80c14dc36f522cf190eb5c4ee3e28008fc6394397c16f62a26258c2", [:rebar3], [], "hexpm"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"}, + "makeup": {:hex, :makeup, "0.5.1", "966c5c2296da272d42f1de178c1d135e432662eca795d6dc12e5e8787514edf7", [:mix], [{:nimble_parsec, "~> 0.2.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "259748a45dfcf5f49765a7c29c9594791c82de23e22d7a3e6e59533fe8e8935b"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.8.0", "1204a2f5b4f181775a0e456154830524cf2207cf4f9112215c05e0b76e4eca8b", [:mix], [{:makeup, "~> 0.5.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 0.2.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "393d17c5a648e3b30522b2a4743bd1dc3533e1227c8c2823ebe8c3a8e5be5913"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm", "7a4c8e1115a2732a67d7624e28cf6c9f30c66711a9e92928e745c255887ba465"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.2.2", "d526b23bdceb04c7ad15b33c57c4526bf5f50aaa70c7c141b4b4624555c68259", [:mix], [], "hexpm", "4ababf5c44164f161872704e1cfbecab3935fdebec66c72905abaad0e6e5cef6"}, + "parse_trans": {:hex, :parse_trans, "3.2.0", "2adfa4daf80c14dc36f522cf190eb5c4ee3e28008fc6394397c16f62a26258c2", [:rebar3], [], "hexpm", "578b1d484720749499db5654091ddac818ea0b6d568f2c99c562d2a6dd4aa117"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm", "4f8805eb5c8a939cf2359367cb651a3180b27dfb48444846be2613d79355d65e"}, + "stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm", "da1d9bef8a092cc7e1e51f1298037a5ddfb0f657fe862dfe7ba4c5807b551c29"}, } diff --git a/shell.nix b/shell.nix new file mode 100644 index 00000000..379b1af5 --- /dev/null +++ b/shell.nix @@ -0,0 +1,8 @@ +let + pkgs = import {}; +in +pkgs.mkShell { + buildInputs = [ + pkgs.elixir + ]; +} diff --git a/test/support/scripted_mqtt_server.exs b/test/support/scripted_mqtt_server.ex similarity index 92% rename from test/support/scripted_mqtt_server.exs rename to test/support/scripted_mqtt_server.ex index 14161298..b4d748e2 100644 --- a/test/support/scripted_mqtt_server.exs +++ b/test/support/scripted_mqtt_server.ex @@ -58,6 +58,11 @@ defmodule Tortoise.Integration.ScriptedMqttServer do end end + def handle_call({:enact, script}, {pid, _} = caller, %State{client_pid: pid} = state) do + GenServer.reply(caller, {:ok, state.server_info}) + next_action(%State{state | script: state.script ++ script}) + end + def handle_call({:enact, script}, {pid, _} = caller, state) do GenServer.reply(caller, {:ok, state.server_info}) {:ok, client} = state.transport.accept(state.server_socket, 200) @@ -77,7 +82,7 @@ defmodule Tortoise.Integration.ScriptedMqttServer do next_action(%State{state | script: script}) otherwise -> - throw({:unexpected_package, otherwise}) + {:stop, {:unexpected_package, otherwise}, state} end end diff --git a/test/support/scripted_transport.exs b/test/support/scripted_transport.ex similarity index 90% rename from test/support/scripted_transport.exs rename to test/support/scripted_transport.ex index 9b547837..028cc649 100644 --- a/test/support/scripted_transport.exs +++ b/test/support/scripted_transport.ex @@ -188,7 +188,14 @@ defmodule Tortoise.Integration.ScriptedTransport do end def handle_call({:connect, opts, _timeout}, {client_pid, _ref}, %State{client: nil} = state) do - state = %State{state | client: client_pid, status: :open, opts: opts} + state = %State{ + state + | client: client_pid, + status: :open, + opts: opts, + controlling_process: client_pid + } + Kernel.send(state.test_process, {__MODULE__, :connected}) {:reply, {:ok, self()}, setup_next(state)} end @@ -268,10 +275,26 @@ defmodule Tortoise.Integration.ScriptedTransport do state end - defp setup_next(%State{script: [{:dispatch, package} | remaining]} = state) do + defp setup_next(%State{script: [{:dispatch, package} | remaining], opts: opts} = state) do data = IO.iodata_to_binary(Tortoise.Package.encode(package)) buffer = state.buffer <> data - %State{state | script: remaining, buffer: buffer} + + case Keyword.pop(opts, :active, false) do + {false, opts} -> + opts = [{:active, false} | opts] + %State{state | opts: opts, script: remaining, buffer: buffer} + + {true, opts} -> + opts = [{:active, true} | opts] + Kernel.send(state.controlling_process, {ScriptedTransport, self(), buffer}) + %State{state | opts: opts, script: remaining, buffer: <<>>} + + {:once, opts} -> + opts = [{:active, false} | opts] + Kernel.send(state.controlling_process, {ScriptedTransport, self(), buffer}) + %State{state | opts: opts, script: remaining, buffer: <<>>} + end + |> setup_next() end defp setup_next(%State{script: [{:expect, _} | _]} = state) do diff --git a/test/support/test_handler.ex b/test/support/test_handler.ex new file mode 100644 index 00000000..15872161 --- /dev/null +++ b/test/support/test_handler.ex @@ -0,0 +1,144 @@ +defmodule TestHandler do + @behaviour Tortoise.Handler + + alias Tortoise.Package + + @impl true + def init(opts) do + state = Enum.into(opts, %{}) + send(state[:parent], {{__MODULE__, :init}, opts}) + {:ok, state} + end + + @impl true + def handle_connack(%Package.Connack{} = connack, state) do + case state[:handle_connack] do + nil -> + send(state[:parent], {{__MODULE__, :handle_connack}, connack}) + {:cont, state} + + fun when is_function(fun, 2) -> + apply(fun, [connack, state]) + end + end + + @impl true + def terminate(reason, state) do + case state[:terminate] do + nil -> + send(state[:parent], {{__MODULE__, :terminate}, reason}) + :ok + + fun when is_function(fun, 2) -> + apply(fun, [reason, state]) + end + end + + @impl true + def status_change(status, state) do + case state[:status_change] do + nil -> + send(state[:parent], {{__MODULE__, :status_change}, status}) + {:cont, state} + + fun when is_function(fun, 2) -> + apply(fun, [status, state]) + end + end + + @impl true + def handle_disconnect({_source, %Package.Disconnect{} = disconnect}, state) do + case state[:handle_disconnect] do + nil -> + send(state[:parent], {{__MODULE__, :handle_disconnect}, disconnect}) + {:cont, state} + + fun when is_function(fun, 2) -> + apply(fun, [disconnect, state]) + end + end + + @impl true + def handle_publish(topic, %Package.Publish{} = publish, state) do + case state[:handle_publish] do + nil -> + send(state[:parent], {{__MODULE__, :handle_publish}, publish}) + {:cont, state} + + fun when is_function(fun, 3) -> + apply(fun, [topic, publish, state]) + end + end + + @impl true + def handle_puback(%Package.Puback{} = puback, state) do + case state[:handle_puback] do + nil -> + send(state[:parent], {{__MODULE__, :handle_puback}, puback}) + {:cont, state} + + fun when is_function(fun, 2) -> + apply(fun, [puback, state]) + end + end + + @impl true + def handle_pubrec(%Package.Pubrec{} = pubrec, state) do + case state[:handle_pubrec] do + nil -> + send(state[:parent], {{__MODULE__, :handle_pubrec}, pubrec}) + {:cont, state} + + fun when is_function(fun, 2) -> + apply(fun, [pubrec, state]) + end + end + + @impl true + def handle_pubrel(%Package.Pubrel{} = pubrel, state) do + case state[:handle_pubrel] do + nil -> + send(state[:parent], {{__MODULE__, :handle_pubrel}, pubrel}) + {:cont, state} + + fun when is_function(fun, 2) -> + apply(fun, [pubrel, state]) + end + end + + @impl true + def handle_pubcomp(%Package.Pubcomp{} = pubcomp, state) do + case state[:handle_pubcomp] do + nil -> + send(state[:parent], {{__MODULE__, :handle_pubcomp}, pubcomp}) + {:cont, state} + + fun when is_function(fun, 2) -> + apply(fun, [pubcomp, state]) + end + end + + @impl true + def handle_suback(%Package.Subscribe{} = subscribe, %Package.Suback{} = suback, state) do + case state[:handle_suback] do + nil -> + send(state[:parent], {{__MODULE__, :handle_suback}, {subscribe, suback}}) + {:cont, state} + + fun when is_function(fun, 3) -> + apply(fun, [subscribe, suback, state]) + end + end + + @impl true + def handle_unsuback(%Package.Unsubscribe{} = unsubscribe, %Package.Unsuback{} = unsuback, state) do + case state[:handle_unsuback] do + nil -> + send(state[:parent], {{__MODULE__, :handle_unsuback}, {unsubscribe, unsuback}}) + {:cont, state} + + fun when is_function(fun, 3) -> + apply(fun, [unsubscribe, unsuback, state]) + end + end +end diff --git a/test/support/test_tcp_tunnel.exs b/test/support/test_tcp_tunnel.ex similarity index 85% rename from test/support/test_tcp_tunnel.exs rename to test/support/test_tcp_tunnel.ex index 93ed8173..a25b35bf 100644 --- a/test/support/test_tcp_tunnel.exs +++ b/test/support/test_tcp_tunnel.ex @@ -4,7 +4,7 @@ defmodule Tortoise.Integration.TestTCPTunnel do send to the client_socket and assert on the received data on the server_socket. - This work for our Transmitter-module which is handled a TCP-socket + This work for our Inflight-module which is handled a TCP-socket from the Receiver. """ use GenServer @@ -30,6 +30,11 @@ defmodule Tortoise.Integration.TestTCPTunnel do end end + def new(transport) do + {ref, {ip, port}} = GenServer.call(__MODULE__, :create) + {:ok, ref, Tortoise.Transport.new({transport, [host: ip, port: port]})} + end + # Server callbacks def init(state) do {:ok, socket} = :gen_tcp.listen(0, [:binary, active: false]) diff --git a/test/test_helper.exs b/test/test_helper.exs index efd233ba..c572c72d 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,258 +1,488 @@ -Code.require_file("./support/test_tcp_tunnel.exs", __DIR__) - defmodule Tortoise.TestGenerators do @moduledoc """ EQC generators for generating variables and data structures useful for testing MQTT """ - use EQC.ExUnit - - alias Tortoise.Package - - def gen_topic() do - let topic_list <- non_empty(list(5, gen_topic_level())) do - Enum.join(topic_list, "/") - end - end - - def gen_topic_filter() do - let topic_list <- non_empty(list(5, gen_topic_level())) do - let {_matching?, filter} <- gen_filter_from_topic(topic_list) do - Enum.join(filter, "/") - end - end - end - - defp gen_topic_level() do - such_that topic <- non_empty(utf8()) do - not String.contains?(topic, ["/", "+", "#"]) - end - end - - # - - - - defp gen_filter_from_topic(topic) do - {_matching?, _filter} = gen_filter(:cont, true, topic, []) - end - - defp gen_filter(_status, matching?, _, ["#" | _] = acc) do - let(result <- Enum.reverse(acc), do: {matching?, result}) - end - - defp gen_filter(:stop, matching?, [t], acc) do - let(result <- Enum.reverse([t | acc]), do: {matching?, result}) - end - - defp gen_filter(:stop, _matching?, _topic_list, acc) do - let(result <- Enum.reverse(acc), do: {false, result}) - end - - defp gen_filter(status, matching?, [], acc) do - frequency([ - {20, {matching?, lazy(do: Enum.reverse(acc))}}, - {5, gen_extra_filter_topic(status, matching?, acc)} - ]) - end - - defp gen_filter(status, matching?, [t | ts], acc) do - frequency([ - # keep - {15, gen_filter(status, matching?, ts, [t | acc])}, - # one level filter - {20, gen_filter(status, matching?, ts, ["+" | acc])}, - # mutate - {10, gen_filter(status, false, ts, [alter_topic_level(t) | acc])}, - # multi-level filter - {5, gen_filter(status, matching?, [], ["#" | acc])}, - # early bail out - {5, gen_filter(:stop, matching?, [t | ts], acc)} - ]) - end - - defp gen_extra_filter_topic(status, _matching?, acc) do - let extra_topic <- gen_topic_level() do - gen_filter(status, false, [extra_topic], acc) - end - end - - # Given a specific topic level return a different one - defp alter_topic_level(topic_level) do - such_that mutation <- gen_topic_level() do - mutation != topic_level - end - end - - # -------------------------------------------------------------------- - def gen_identifier() do - choose(0x0001, 0xFFFF) - end - - def gen_qos() do - choose(0, 2) - end - - @doc """ - Generate a valid connect message - """ - def gen_connect() do - let will <- - oneof([ - nil, - %Package.Publish{ - topic: gen_topic(), - payload: oneof([non_empty(binary()), nil]), - qos: gen_qos(), - retain: bool() - } - ]) do - # zero byte client id is allowed, but clean session should be set to true - let connect <- %Package.Connect{ - # The Server MUST allow ClientIds which are between 1 and 23 - # UTF-8 encoded bytes in length, and that contain only the - # characters - # "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - # [MQTT-3.1.3-5]. - - # The Server MAY allow ClientId’s that contain more than 23 - # encoded bytes. The Server MAY allow ClientId’s that contain - # characters not included in the list given above. - client_id: binary(), - user_name: oneof([nil, utf8()]), - password: oneof([nil, utf8()]), - clean_session: bool(), - keep_alive: choose(0, 65535), - will: will - } do - connect - end - end - end - - @doc """ - Generate a valid connack (connection acknowledgement) message - """ - def gen_connack() do - let connack <- %Package.Connack{ - session_present: bool(), - status: - oneof([ - :accepted, - {:refused, :unacceptable_protocol_version}, - {:refused, :identifier_rejected}, - {:refused, :server_unavailable}, - {:refused, :bad_user_name_or_password}, - {:refused, :not_authorized} - ]) - } do - connack - end - end - - @doc """ - Generate a valid publish message. - - A publish message with a quality of zero will not have an identifier - or ever be a duplicate message, so we generate the quality of - service first and decide if we should generate values for those - values depending on the value of the generated QoS. - """ - def gen_publish() do - let qos <- gen_qos() do - %{ - do_gen_publish(qos) - | topic: gen_topic(), - payload: oneof([non_empty(binary()), nil]), - retain: bool() - } - end - end - - defp do_gen_publish(0) do - %Package.Publish{identifier: nil, qos: 0, dup: false} - end - - defp do_gen_publish(qos) do - %Package.Publish{ - identifier: gen_identifier(), - qos: qos, - dup: bool() - } - end - - @doc """ - Generate a valid subscribe message. - - The message will get populated with one or more topic filters, each - with a quality of service between 0 and 2. - """ - def gen_subscribe() do - let subscribe <- %Package.Subscribe{ - identifier: gen_identifier(), - topics: non_empty(list({gen_topic_filter(), gen_qos()})) - } do - subscribe - end - end - - def gen_suback() do - let suback <- %Package.Suback{ - identifier: choose(0x0001, 0xFFFF), - acks: non_empty(list(oneof([{:ok, gen_qos()}, {:error, :access_denied}]))) - } do - suback - end - end - - @doc """ - Generate a valid unsubscribe message. - """ - def gen_unsubscribe() do - let unsubscribe <- %Package.Unsubscribe{ - identifier: gen_identifier(), - topics: non_empty(list(gen_topic_filter())) - } do - unsubscribe - end - end - - def gen_unsuback() do - let unsuback <- %Package.Unsuback{ - identifier: gen_identifier() - } do - unsuback - end - end - - def gen_puback() do - let puback <- %Package.Puback{ - identifier: gen_identifier() - } do - puback - end - end - - def gen_pubcomp() do - let pubcomp <- %Package.Pubcomp{ - identifier: gen_identifier() - } do - pubcomp - end - end - - def gen_pubrel() do - let pubrel <- %Package.Pubrel{ - identifier: gen_identifier() - } do - pubrel - end - end - - def gen_pubrec() do - let pubrec <- %Package.Pubrec{ - identifier: gen_identifier() - } do - pubrec - end - end + # use EQC.ExUnit + + # alias Tortoise.Package + + # def gen_topic() do + # let topic_list <- non_empty(list(5, gen_topic_level())) do + # Enum.join(topic_list, "/") + # end + # end + + # def gen_topic_filter() do + # let topic_list <- non_empty(list(5, gen_topic_level())) do + # let {_matching?, filter} <- gen_filter_from_topic(topic_list) do + # Enum.join(filter, "/") + # end + # end + # end + + # defp gen_topic_level() do + # such_that topic <- non_empty(utf8()) do + # not String.contains?(topic, ["/", "+", "#"]) + # end + # end + + # # - - - + # defp gen_filter_from_topic(topic) do + # {_matching?, _filter} = gen_filter(:cont, true, topic, []) + # end + + # defp gen_filter(_status, matching?, _, ["#" | _] = acc) do + # let(result <- Enum.reverse(acc), do: {matching?, result}) + # end + + # defp gen_filter(:stop, matching?, [t], acc) do + # let(result <- Enum.reverse([t | acc]), do: {matching?, result}) + # end + + # defp gen_filter(:stop, _matching?, _topic_list, acc) do + # let(result <- Enum.reverse(acc), do: {false, result}) + # end + + # defp gen_filter(status, matching?, [], acc) do + # frequency([ + # {20, {matching?, lazy(do: Enum.reverse(acc))}}, + # {5, gen_extra_filter_topic(status, matching?, acc)} + # ]) + # end + + # defp gen_filter(status, matching?, [t | ts], acc) do + # frequency([ + # # keep + # {15, gen_filter(status, matching?, ts, [t | acc])}, + # # one level filter + # {20, gen_filter(status, matching?, ts, ["+" | acc])}, + # # mutate + # {10, gen_filter(status, false, ts, [alter_topic_level(t) | acc])}, + # # multi-level filter + # {5, gen_filter(status, matching?, [], ["#" | acc])}, + # # early bail out + # {5, gen_filter(:stop, matching?, [t | ts], acc)} + # ]) + # end + + # defp gen_extra_filter_topic(status, _matching?, acc) do + # let extra_topic <- gen_topic_level() do + # gen_filter(status, false, [extra_topic], acc) + # end + # end + + # # Given a specific topic level return a different one + # defp alter_topic_level(topic_level) do + # such_that mutation <- gen_topic_level() do + # mutation != topic_level + # end + # end + + # # -------------------------------------------------------------------- + # def gen_identifier() do + # choose(0x0001, 0xFFFF) + # end + + # def gen_qos() do + # choose(0, 2) + # end + + # @doc """ + # Generate a valid connect message + # """ + # def gen_connect() do + # let will <- + # oneof([ + # nil, + # %Package.Publish{ + # topic: gen_topic(), + # payload: oneof([non_empty(binary()), nil]), + # qos: gen_qos(), + # retain: bool(), + # properties: [receive_maximum: 201] + # } + # ]) do + # # zero byte client id is allowed, but clean session should be set to true + # let connect <- %Package.Connect{ + # # The Server MUST allow ClientIds which are between 1 and 23 + # # UTF-8 encoded bytes in length, and that contain only the + # # characters + # # "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + # # [MQTT-3.1.3-5]. + + # # The Server MAY allow ClientId’s that contain more than 23 + # # encoded bytes. The Server MAY allow ClientId’s that contain + # # characters not included in the list given above. + # client_id: binary(), + # user_name: oneof([nil, utf8()]), + # password: oneof([nil, utf8()]), + # clean_start: bool(), + # keep_alive: choose(0, 65535), + # will: will, + # properties: [receive_maximum: 201] + # } do + # connect + # end + # end + # end + + # @doc """ + # Generate a valid connack (connection acknowledgement) message + # """ + # def gen_connack() do + # let connack <- %Package.Connack{ + # session_present: bool(), + # reason: + # oneof([ + # :success, + # {:refused, :unspecified_error}, + # {:refused, :malformed_packet}, + # {:refused, :protocol_error}, + # {:refused, :implementation_specific_error}, + # {:refused, :unsupported_protocol_version}, + # {:refused, :client_identifier_not_valid}, + # {:refused, :bad_user_name_or_password}, + # {:refused, :not_authorized}, + # {:refused, :server_unavailable}, + # {:refused, :server_busy}, + # {:refused, :banned}, + # {:refused, :bad_authentication_method}, + # {:refused, :topic_name_invalid}, + # {:refused, :packet_too_large}, + # {:refused, :quota_exceeded}, + # {:refused, :payload_format_invalid}, + # {:refused, :retain_not_supported}, + # {:refused, :qos_not_supported}, + # {:refused, :use_another_server}, + # {:refused, :server_moved}, + # {:refused, :connection_rate_exceeded} + # ]) + # } do + # connack + # end + # end + + # @doc """ + # Generate a valid publish message. + + # A publish message with a quality of zero will not have an identifier + # or ever be a duplicate message, so we generate the quality of + # service first and decide if we should generate values for those + # values depending on the value of the generated QoS. + # """ + # def gen_publish() do + # let qos <- gen_qos() do + # %{ + # do_gen_publish(qos) + # | topic: gen_topic(), + # payload: oneof([non_empty(binary()), nil]), + # retain: bool() + # } + # |> gen_publish_properties() + # end + # end + + # defp do_gen_publish(0) do + # %Package.Publish{identifier: nil, qos: 0, dup: false} + # end + + # defp do_gen_publish(qos) do + # %Package.Publish{ + # identifier: gen_identifier(), + # qos: qos, + # dup: bool() + # } + # end + + # defp gen_publish_properties(%Package.Publish{} = publish) do + # allowed_properties = [ + # :payload_format_indicator, + # :message_expiry_interval, + # :topic_alias, + # :response_topic, + # :correlation_data, + # :user_property, + # :subscription_identifier, + # :content_type + # ] + + # let properties <- list(5, oneof(allowed_properties)) do + # # @todo only user_properties and subscription_identifiers are allowed multiple times + # properties = Enum.map(properties, &gen_property_value/1) + # %Package.Publish{publish | properties: properties} + # end + # end + + # @doc """ + # Generate a valid subscribe message. + + # The message will get populated with one or more topic filters, each + # with a quality of service between 0 and 2. + # """ + # def gen_subscribe() do + # let subscribe <- %Package.Subscribe{ + # identifier: gen_identifier(), + # topics: non_empty(list({gen_topic_filter(), gen_subscribe_opts()})), + # # todo, add properties + # properties: [] + # } do + # subscribe + # end + # end + + # # @todo improve this generator + # def gen_subscribe_opts() do + # let {qos, no_local, retain_as_published, retain_handling} <- + # {gen_qos(), bool(), bool(), choose(0, 3)} do + # [ + # qos: qos, + # no_local: no_local, + # retain_as_published: retain_as_published, + # retain_handling: retain_handling + # ] + # end + # end + + # def gen_suback() do + # let suback <- %Package.Suback{ + # identifier: choose(0x0001, 0xFFFF), + # acks: + # non_empty( + # list( + # oneof([ + # {:ok, gen_qos()}, + # {:error, + # oneof([ + # :unspecified_error, + # :implementation_specific_error, + # :not_authorized, + # :topic_filter_invalid, + # :packet_identifier_in_use, + # :quota_exceeded, + # :shared_subscriptions_not_supported, + # :subscription_identifiers_not_supported, + # :wildcard_subscriptions_not_supported + # ])} + # ]) + # ) + # ), + # # todo, add generators for [:reason_string, :user_property] + # properties: [] + # } do + # suback + # end + # end + + # @doc """ + # Generate a valid unsubscribe message. + # """ + # def gen_unsubscribe() do + # let unsubscribe <- %Package.Unsubscribe{ + # identifier: gen_identifier(), + # topics: non_empty(list(gen_topic_filter())), + # properties: [] + # } do + # unsubscribe + # end + # end + + # def gen_unsuback() do + # let unsuback <- %Package.Unsuback{ + # identifier: gen_identifier(), + # results: + # non_empty( + # list( + # oneof([ + # :success, + # {:error, + # oneof([ + # :no_subscription_existed, + # :unspecified_error, + # :implementation_specific_error, + # :not_authorized, + # :topic_filter_invalid, + # :packet_identifier_in_use + # ])} + # ]) + # ) + # ), + # # todo, generate :reason_string and :user_property + # properties: [] + # } do + # unsuback + # end + # end + + # def gen_puback() do + # # todo, make this generator generate properties and other reasons + # let puback <- %Package.Puback{ + # identifier: gen_identifier(), + # reason: :success, + # properties: [] + # } do + # puback + # end + # end + + # def gen_pubcomp() do + # let pubcomp <- %Package.Pubcomp{ + # identifier: gen_identifier(), + # reason: {:refused, :packet_identifier_not_found}, + # properties: [] + # } do + # pubcomp + # end + # end + + # def gen_pubrel() do + # # todo, improve this generator + # let pubrel <- %Package.Pubrel{ + # identifier: gen_identifier(), + # reason: :success, + # properties: [] + # } do + # pubrel + # end + # end + + # def gen_pubrec() do + # # todo, improve this generator + # let pubrec <- %Package.Pubrec{ + # identifier: gen_identifier(), + # reason: :success, + # properties: [] + # } do + # pubrec + # end + # end + + # def gen_disconnect() do + # let disconnect <- + # %Package.Disconnect{ + # reason: + # oneof([ + # :normal_disconnection, + # :disconnect_with_will_message, + # :unspecified_error, + # :malformed_packet, + # :protocol_error, + # :implementation_specific_error, + # :not_authorized, + # :server_busy, + # :server_shutting_down, + # :keep_alive_timeout, + # :session_taken_over, + # :topic_filter_invalid, + # :topic_name_invalid, + # :receive_maximum_exceeded, + # :topic_alias_invalid, + # :packet_too_large, + # :message_rate_too_high, + # :quota_exceeded, + # :administrative_action, + # :payload_format_invalid, + # :retain_not_supported, + # :qos_not_supported, + # :use_another_server, + # :server_moved, + # :shared_subscriptions_not_supported, + # :connection_rate_exceeded, + # :maximum_connect_time, + # :subscription_identifiers_not_supported, + # :wildcard_subscriptions_not_supported + # ]) + # } do + # %Package.Disconnect{disconnect | properties: gen_properties(disconnect)} + # end + # end + + # def gen_auth() do + # let auth <- + # %Package.Auth{ + # reason: oneof([:success, :continue_authentication, :re_authenticate]) + # } do + # %Package.Auth{auth | properties: gen_properties(auth)} + # end + # end + + # def gen_properties(%Package.Disconnect{reason: :normal_disconnection}) do + # [] + # end + + # def gen_properties(%{}) do + # [] + # end + + # def gen_properties() do + # let properties <- + # list( + # 5, + # oneof([ + # :payload_format_indicator, + # :message_expiry_interval, + # :content_type, + # :response_topic, + # :correlation_data, + # :subscription_identifier, + # :session_expiry_interval, + # :assigned_client_identifier, + # :server_keep_alive, + # :authentication_method, + # :authentication_data, + # :request_problem_information, + # :will_delay_interval, + # :request_response_information, + # :response_information, + # :server_reference, + # :reason_string, + # :receive_maximum, + # :topic_alias_maximum, + # :topic_alias, + # :maximum_qos, + # :retain_available, + # :user_property, + # :maximum_packet_size, + # :wildcard_subscription_available, + # :subscription_identifiers_available, + # :shared_subscription_available + # ]) + # ) do + # Enum.map(properties, &gen_property_value/1) + # end + # end + + # def gen_property_value(type) do + # case type do + # :payload_format_indicator -> {type, oneof([0, 1])} + # :message_expiry_interval -> {type, choose(0, 4_294_967_295)} + # :content_type -> {type, utf8()} + # :response_topic -> {type, gen_topic()} + # :correlation_data -> {type, binary()} + # :subscription_identifier -> {type, choose(1, 268_435_455)} + # :session_expiry_interval -> {type, choose(1, 268_435_455)} + # :assigned_client_identifier -> {type, utf8()} + # :server_keep_alive -> {type, choose(0x0000, 0xFFFF)} + # :authentication_method -> {type, utf8()} + # :authentication_data -> {type, binary()} + # :request_problem_information -> {type, bool()} + # :will_delay_interval -> {type, choose(0, 4_294_967_295)} + # :request_response_information -> {type, bool()} + # :response_information -> {type, utf8()} + # :server_reference -> {type, utf8()} + # :reason_string -> {type, utf8()} + # :receive_maximum -> {type, choose(0x0001, 0xFFFF)} + # :topic_alias_maximum -> {type, choose(0x0000, 0xFFFF)} + # :topic_alias -> {type, choose(0x0001, 0xFFFF)} + # :maximum_qos -> {type, oneof([0, 1])} + # :retain_available -> {type, bool()} + # :user_property -> {type, {utf8(), utf8()}} + # :maximum_packet_size -> {type, choose(1, 268_435_455)} + # :wildcard_subscription_available -> {type, bool()} + # :subscription_identifiers_available -> {type, bool()} + # :shared_subscription_available -> {type, bool()} + # end + # end end # make certs for tests using the SSL transport diff --git a/test/tortoise/connection/backoff_test.exs b/test/tortoise/connection/backoff_test.exs index b9ed7de0..b89be98d 100644 --- a/test/tortoise/connection/backoff_test.exs +++ b/test/tortoise/connection/backoff_test.exs @@ -8,18 +8,22 @@ defmodule Tortoise.Connection.BackoffTest do min = 100 max = 300 backoff = Backoff.new(min_interval: 100, max_interval: 300) + assert {0, backoff} = Backoff.next(backoff) assert {^min, backoff} = Backoff.next(backoff) {_, backoff} = Backoff.next(backoff) assert {^max, backoff} = Backoff.next(backoff) - # should roll back to min interval now + # should start over now + assert {0, backoff} = Backoff.next(backoff) assert {^min, _} = Backoff.next(backoff) end test "reset" do backoff = Backoff.new(min_interval: 10) + assert {0, backoff} = Backoff.next(backoff) assert {_, backoff = snapshot} = Backoff.next(backoff) assert {_, backoff} = Backoff.next(backoff) assert %Backoff{} = backoff = Backoff.reset(backoff) + assert {0, backoff} = Backoff.next(backoff) assert {_, ^snapshot} = Backoff.next(backoff) end end diff --git a/test/tortoise/connection/controller_test.exs b/test/tortoise/connection/controller_test.exs deleted file mode 100644 index 76eba59e..00000000 --- a/test/tortoise/connection/controller_test.exs +++ /dev/null @@ -1,560 +0,0 @@ -defmodule Tortoise.Connection.ControllerTest do - use ExUnit.Case - doctest Tortoise.Connection.Controller - - alias Tortoise.Package - alias Tortoise.Connection.{Controller, Inflight} - - import ExUnit.CaptureLog - - defmodule TestHandler do - use Tortoise.Handler - - defstruct pid: nil, - client_id: nil, - status: nil, - publish_count: 0, - received: [], - subscriptions: [] - - def init([client_id, caller]) when is_pid(caller) do - # We pass in the caller `pid` and keep it in the state so we can - # send messages back to the test process, which will make it - # possible to make assertions on the changes in the handler - # callback module - {:ok, %__MODULE__{pid: caller, client_id: client_id}} - end - - def connection(status, state) do - new_state = %__MODULE__{state | status: status} - send(state.pid, new_state) - {:ok, new_state} - end - - def subscription(:up, topic_filter, state) do - new_state = %__MODULE__{ - state - | subscriptions: [{topic_filter, :ok} | state.subscriptions] - } - - send(state.pid, new_state) - {:ok, new_state} - end - - def subscription(:down, topic_filter, state) do - new_state = %__MODULE__{ - state - | subscriptions: - Enum.reject(state.subscriptions, fn {topic, _} -> topic == topic_filter end) - } - - send(state.pid, new_state) - {:ok, new_state} - end - - def subscription({:warn, warning}, topic_filter, state) do - new_state = %__MODULE__{ - state - | subscriptions: [{topic_filter, warning} | state.subscriptions] - } - - send(state.pid, new_state) - {:ok, new_state} - end - - def subscription({:error, reason}, topic_filter, state) do - send(state.pid, {:subscription_error, {topic_filter, reason}}) - {:ok, state} - end - - def handle_message(topic, message, %__MODULE__{} = state) do - new_state = %__MODULE__{ - state - | publish_count: state.publish_count + 1, - received: [{topic, message} | state.received] - } - - send(state.pid, new_state) - {:ok, new_state} - end - - def terminate(reason, state) do - send(state.pid, {:terminating, reason}) - :ok - end - end - - # Setup ============================================================== - setup context do - {:ok, %{client_id: context.test}} - end - - def setup_controller(context) do - handler = %Tortoise.Handler{ - module: __MODULE__.TestHandler, - initial_args: [context.client_id, self()] - } - - opts = [client_id: context.client_id, handler: handler] - {:ok, pid} = Controller.start_link(opts) - {:ok, %{controller_pid: pid}} - end - - def setup_connection(context) do - {:ok, client_socket, server_socket} = Tortoise.Integration.TestTCPTunnel.new() - name = Tortoise.Connection.via_name(context.client_id) - :ok = Tortoise.Registry.put_meta(name, {Tortoise.Transport.Tcp, client_socket}) - {:ok, %{client: client_socket, server: server_socket}} - end - - def setup_inflight(context) do - opts = [client_id: context.client_id] - {:ok, pid} = Inflight.start_link(opts) - {:ok, %{inflight_pid: pid}} - end - - # tests -------------------------------------------------------------- - test "life cycle", context do - handler = %Tortoise.Handler{ - module: __MODULE__.TestHandler, - initial_args: [context.client_id, self()] - } - - opts = [client_id: context.client_id, handler: handler] - assert {:ok, pid} = Controller.start_link(opts) - assert Process.alive?(pid) - assert :ok = Controller.stop(context.client_id) - refute Process.alive?(pid) - assert_receive {:terminating, :normal} - end - - describe "Connection callback" do - setup [:setup_controller] - - test "Callback is triggered on connection status change", context do - # tell the controller that we are up - :ok = Tortoise.Events.dispatch(context.client_id, :status, :up) - assert_receive(%TestHandler{status: :up}) - # switch to offline - :ok = Tortoise.Events.dispatch(context.client_id, :status, :down) - assert_receive(%TestHandler{status: :down}) - # ... and back up - :ok = Tortoise.Events.dispatch(context.client_id, :status, :up) - assert_receive(%TestHandler{status: :up}) - end - end - - describe "Connection Control Packets" do - setup [:setup_controller] - - test "receiving a connect from the server is a protocol violation", - %{controller_pid: pid} = context do - Process.flag(:trap_exit, true) - # receiving a connect from the server is a protocol violation - connect = %Package.Connect{client_id: "foo"} - Controller.handle_incoming(context.client_id, connect) - - assert_receive {:EXIT, ^pid, - {:protocol_violation, {:unexpected_package_from_remote, ^connect}}} - end - - test "receiving a connack at this point is a protocol violation", - %{controller_pid: pid} = context do - Process.flag(:trap_exit, true) - # receiving a connack from the server *after* the connection has - # been acknowledged is a protocol violation - connack = %Package.Connack{status: :accepted} - Controller.handle_incoming(context.client_id, connack) - - assert_receive {:EXIT, ^pid, - {:protocol_violation, {:unexpected_package_from_remote, ^connack}}} - end - - test "receiving a disconnect from the server is a protocol violation", - %{controller_pid: pid} = context do - Process.flag(:trap_exit, true) - # receiving a disconnect request from the server is a (3.1.1) - # protocol violation - disconnect = %Package.Disconnect{} - Controller.handle_incoming(context.client_id, disconnect) - - assert_receive {:EXIT, ^pid, - {:protocol_violation, {:unexpected_package_from_remote, ^disconnect}}} - end - end - - describe "Ping Control Packets" do - setup [:setup_connection, :setup_controller] - - test "send a ping request", context do - # send a ping request to the server - assert {:ok, ping_ref} = Controller.ping(context.client_id) - # assert that the server receives a ping request package - {:ok, package} = :gen_tcp.recv(context.server, 0, 200) - assert %Package.Pingreq{} = Package.decode(package) - # the server will respond with an pingresp (ping response) - Controller.handle_incoming(context.client_id, %Package.Pingresp{}) - assert_receive {Tortoise, {:ping_response, ^ping_ref, _ping_time}} - end - - test "send a sync ping request", context do - # send a ping request to the server - parent = self() - - spawn_link(fn -> - {:ok, time} = Controller.ping_sync(context.client_id) - send(parent, {:ping_result, time}) - end) - - # assert that the server receives a ping request package - {:ok, package} = :gen_tcp.recv(context.server, 0, 200) - assert %Package.Pingreq{} = Package.decode(package) - # the server will respond with an pingresp (ping response) - Controller.handle_incoming(context.client_id, %Package.Pingresp{}) - assert_receive {:ping_result, _time} - end - - test "receiving a ping request", %{controller_pid: pid} = context do - Process.flag(:trap_exit, true) - # receiving a ping request from the server is a protocol violation - pingreq = %Package.Pingreq{} - Controller.handle_incoming(context.client_id, pingreq) - - assert_receive {:EXIT, ^pid, - {:protocol_violation, {:unexpected_package_from_remote, ^pingreq}}} - end - - test "ping request reports are sent in the correct order", context do - # send two ping requests to the server - assert {:ok, first_ping_ref} = Controller.ping(context.client_id) - assert {:ok, second_ping_ref} = Controller.ping(context.client_id) - - # the controller should respond to ping requests in FIFO order - Controller.handle_incoming(context.client_id, %Package.Pingresp{}) - assert_receive {Tortoise, {:ping_response, ^first_ping_ref, _}} - Controller.handle_incoming(context.client_id, %Package.Pingresp{}) - assert_receive {Tortoise, {:ping_response, ^second_ping_ref, _}} - end - end - - describe "publish" do - setup [:setup_controller] - - test "receive a publish", context do - publish = %Package.Publish{ - topic: "foo/bar/baz", - payload: "how do you do?", - qos: 0 - } - - assert :ok = Controller.handle_incoming(context.client_id, publish) - topic_list = String.split(publish.topic, "/") - payload = publish.payload - assert_receive(%TestHandler{received: [{^topic_list, ^payload} | _]}) - end - - test "update callback module state between publishes", context do - publish = %Package.Publish{topic: "a", qos: 0} - # Our callback module will increment a counter when it receives - # a publish control packet - :ok = Controller.handle_incoming(context.client_id, publish) - assert_receive %TestHandler{publish_count: 1} - :ok = Controller.handle_incoming(context.client_id, publish) - assert_receive %TestHandler{publish_count: 2} - end - end - - describe "Publish Control Packets with Quality of Service level 1" do - setup [:setup_connection, :setup_controller, :setup_inflight] - - test "incoming publish with qos 1", context do - # receive a publish message with a qos of 1 - publish = %Package.Publish{identifier: 1, topic: "a", qos: 1} - Controller.handle_incoming(context.client_id, publish) - - # a puback message should get transmitted - {:ok, package} = :gen_tcp.recv(context.server, 0, 200) - assert %Package.Puback{identifier: 1} = Package.decode(package) - end - - test "outgoing publish with qos 1", context do - client_id = context.client_id - publish = %Package.Publish{identifier: 1, topic: "a", qos: 1} - # we will get a reference (not the message id). - assert {:ok, ref} = Inflight.track(client_id, {:outgoing, publish}) - - # assert that the server receives a publish package - {:ok, package} = :gen_tcp.recv(context.server, 0, 200) - assert ^publish = Package.decode(package) - # the server will send back an ack message - Controller.handle_incoming(client_id, %Package.Puback{identifier: 1}) - # the caller should get a message in its mailbox - assert_receive {{Tortoise, ^client_id}, ^ref, :ok} - end - - test "outgoing publish with qos 1 sync call", context do - client_id = context.client_id - publish = %Package.Publish{identifier: 1, topic: "a", qos: 1} - - # setup a blocking call - {caller, test_ref} = {self(), make_ref()} - - spawn_link(fn -> - test_result = Inflight.track_sync(client_id, {:outgoing, publish}) - send(caller, {:sync_call_result, test_ref, test_result}) - end) - - # assert that the server receives a publish package - {:ok, package} = :gen_tcp.recv(context.server, 0, 200) - assert ^publish = Package.decode(package) - # the server will send back an ack message - Controller.handle_incoming(client_id, %Package.Puback{identifier: 1}) - # the blocking call should receive :ok when the message is acked - assert_receive {:sync_call_result, ^test_ref, :ok} - end - end - - describe "Publish Quality of Service level 2" do - setup [:setup_connection, :setup_controller, :setup_inflight] - - test "incoming publish with qos 2", context do - client_id = context.client_id - # send in an publish message with a QoS of 2 - publish = %Package.Publish{identifier: 1, topic: "a", qos: 2} - :ok = Controller.handle_incoming(client_id, publish) - # test sending in a duplicate publish - :ok = Controller.handle_incoming(client_id, %Package.Publish{publish | dup: true}) - - # assert that the sender receives a pubrec package - {:ok, pubrec} = :gen_tcp.recv(context.server, 0, 200) - assert %Package.Pubrec{identifier: 1} = Package.decode(pubrec) - - # the publish should get onwarded to the handler - assert_receive %TestHandler{publish_count: 1, received: [{["a"], nil}]} - - # the MQTT server will then respond with pubrel - Controller.handle_incoming(client_id, %Package.Pubrel{identifier: 1}) - # a pubcomp message should get transmitted - {:ok, pubcomp} = :gen_tcp.recv(context.server, 0, 200) - - assert %Package.Pubcomp{identifier: 1} = Package.decode(pubcomp) - - # the publish should only get onwareded once - refute_receive %TestHandler{publish_count: 2} - end - - test "incoming publish with qos 2 (first message dup)", context do - # send in an publish with dup set to true should succeed if the - # id is unknown. - client_id = context.client_id - # send in an publish message with a QoS of 2 - publish = %Package.Publish{identifier: 1, topic: "a", qos: 2, dup: true} - :ok = Controller.handle_incoming(client_id, publish) - - # assert that the sender receives a pubrec package - {:ok, pubrec} = :gen_tcp.recv(context.server, 0, 200) - assert %Package.Pubrec{identifier: 1} = Package.decode(pubrec) - - # the MQTT server will then respond with pubrel - Controller.handle_incoming(client_id, %Package.Pubrel{identifier: 1}) - # a pubcomp message should get transmitted - {:ok, pubcomp} = :gen_tcp.recv(context.server, 0, 200) - - assert %Package.Pubcomp{identifier: 1} = Package.decode(pubcomp) - - # the publish should get onwarded to the handler - assert_receive %TestHandler{publish_count: 1, received: [{["a"], nil}]} - end - - test "outgoing publish with qos 2", context do - client_id = context.client_id - publish = %Package.Publish{identifier: 1, topic: "a", qos: 2} - - assert {:ok, ref} = Inflight.track(client_id, {:outgoing, publish}) - - # assert that the server receives a publish package - {:ok, package} = :gen_tcp.recv(context.server, 0, 200) - assert ^publish = Package.decode(package) - # the server will send back a publish received message - Controller.handle_incoming(client_id, %Package.Pubrec{identifier: 1}) - # we should send a publish release (pubrel) to the server - {:ok, package} = :gen_tcp.recv(context.server, 0, 200) - assert %Package.Pubrel{identifier: 1} = Package.decode(package) - # receive pubcomp - Controller.handle_incoming(client_id, %Package.Pubcomp{identifier: 1}) - # the caller should get a message in its mailbox - assert_receive {{Tortoise, ^client_id}, ^ref, :ok} - end - end - - describe "Subscription" do - setup [:setup_connection, :setup_controller, :setup_inflight] - - test "Subscribe to multiple topics", context do - client_id = context.client_id - - subscribe = %Package.Subscribe{ - identifier: 1, - topics: [{"foo", 0}, {"bar", 1}, {"baz", 2}] - } - - suback = %Package.Suback{identifier: 1, acks: [{:ok, 0}, {:ok, 1}, {:ok, 2}]} - - assert {:ok, ref} = Inflight.track(client_id, {:outgoing, subscribe}) - - # assert that the server receives a subscribe package - {:ok, package} = :gen_tcp.recv(context.server, 0, 200) - assert ^subscribe = Package.decode(package) - # the server will send back a subscription acknowledgement message - :ok = Controller.handle_incoming(client_id, suback) - - assert_receive {{Tortoise, ^client_id}, ^ref, _} - # the client callback module should get the subscribe notifications in order - assert_receive %TestHandler{subscriptions: [{"foo", :ok}]} - assert_receive %TestHandler{subscriptions: [{"bar", :ok} | _]} - assert_receive %TestHandler{subscriptions: [{"baz", :ok} | _]} - - # unsubscribe from a topic - unsubscribe = %Package.Unsubscribe{identifier: 2, topics: ["foo", "baz"]} - unsuback = %Package.Unsuback{identifier: 2} - assert {:ok, ref} = Inflight.track(client_id, {:outgoing, unsubscribe}) - {:ok, package} = :gen_tcp.recv(context.server, 0, 200) - assert ^unsubscribe = Package.decode(package) - :ok = Controller.handle_incoming(client_id, unsuback) - assert_receive {{Tortoise, ^client_id}, ^ref, _} - - # the client callback module should remove the subscriptions in order - assert_receive %TestHandler{subscriptions: [{"baz", :ok}, {"bar", :ok}]} - assert_receive %TestHandler{subscriptions: [{"bar", :ok}]} - end - - test "Subscribe to a topic that return different QoS than requested", context do - client_id = context.client_id - - subscribe = %Package.Subscribe{ - identifier: 1, - topics: [{"foo", 2}] - } - - suback = %Package.Suback{identifier: 1, acks: [{:ok, 0}]} - - assert {:ok, ref} = Inflight.track(client_id, {:outgoing, subscribe}) - - # assert that the server receives a subscribe package - {:ok, package} = :gen_tcp.recv(context.server, 0, 200) - assert ^subscribe = Package.decode(package) - # the server will send back a subscription acknowledgement message - :ok = Controller.handle_incoming(client_id, suback) - - assert_receive {{Tortoise, ^client_id}, ^ref, _} - # the client callback module should get the subscribe notifications in order - assert_receive %TestHandler{subscriptions: [{"foo", [requested: 2, accepted: 0]}]} - - # unsubscribe from a topic - unsubscribe = %Package.Unsubscribe{identifier: 2, topics: ["foo"]} - unsuback = %Package.Unsuback{identifier: 2} - assert {:ok, ref} = Inflight.track(client_id, {:outgoing, unsubscribe}) - {:ok, package} = :gen_tcp.recv(context.server, 0, 200) - assert ^unsubscribe = Package.decode(package) - :ok = Controller.handle_incoming(client_id, unsuback) - assert_receive {{Tortoise, ^client_id}, ^ref, _} - - # the client callback module should remove the subscription - assert_receive %TestHandler{subscriptions: []} - end - - test "Subscribe to a topic resulting in an error", context do - client_id = context.client_id - - subscribe = %Package.Subscribe{ - identifier: 1, - topics: [{"foo", 1}] - } - - suback = %Package.Suback{identifier: 1, acks: [{:error, :access_denied}]} - - assert {:ok, ref} = Inflight.track(client_id, {:outgoing, subscribe}) - - # assert that the server receives a subscribe package - {:ok, package} = :gen_tcp.recv(context.server, 0, 200) - assert ^subscribe = Package.decode(package) - # the server will send back a subscription acknowledgement message - :ok = Controller.handle_incoming(client_id, suback) - - assert_receive {{Tortoise, ^client_id}, ^ref, _} - # the callback module should get the error - assert_receive {:subscription_error, {"foo", :access_denied}} - end - - test "Receiving a subscribe package is a protocol violation", - %{controller_pid: pid} = context do - Process.flag(:trap_exit, true) - # receiving a subscribe from the server is a protocol violation - subscribe = %Package.Subscribe{ - identifier: 1, - topics: [{"foo/bar", 0}] - } - - Controller.handle_incoming(context.client_id, subscribe) - - assert_receive {:EXIT, ^pid, - {:protocol_violation, {:unexpected_package_from_remote, ^subscribe}}} - end - - test "Receiving an unsubscribe package is a protocol violation", - %{controller_pid: pid} = context do - Process.flag(:trap_exit, true) - # receiving an unsubscribe from the server is a protocol violation - unsubscribe = %Package.Unsubscribe{ - identifier: 1, - topics: ["foo/bar"] - } - - Controller.handle_incoming(context.client_id, unsubscribe) - - assert_receive {:EXIT, ^pid, - {:protocol_violation, {:unexpected_package_from_remote, ^unsubscribe}}} - end - end - - describe "next actions" do - setup [:setup_controller] - - test "subscribe action", context do - client_id = context.client_id - next_action = {:subscribe, "foo/bar", qos: 0} - send(context.controller_pid, {:next_action, next_action}) - %{awaiting: awaiting} = Controller.info(client_id) - assert [{ref, ^next_action}] = Map.to_list(awaiting) - response = {{Tortoise, client_id}, ref, :ok} - send(context.controller_pid, response) - %{awaiting: awaiting} = Controller.info(client_id) - assert [] = Map.to_list(awaiting) - end - - test "unsubscribe action", context do - client_id = context.client_id - next_action = {:unsubscribe, "foo/bar"} - send(context.controller_pid, {:next_action, next_action}) - %{awaiting: awaiting} = Controller.info(client_id) - assert [{ref, ^next_action}] = Map.to_list(awaiting) - response = {{Tortoise, client_id}, ref, :ok} - send(context.controller_pid, response) - %{awaiting: awaiting} = Controller.info(client_id) - assert [] = Map.to_list(awaiting) - end - - test "receiving unknown async ref", context do - client_id = context.client_id - ref = make_ref() - - assert capture_log(fn -> - send(context.controller_pid, {{Tortoise, client_id}, ref, :ok}) - :timer.sleep(100) - end) =~ "Unexpected" - - %{awaiting: awaiting} = Controller.info(client_id) - assert [] = Map.to_list(awaiting) - end - end -end diff --git a/test/tortoise/connection/inflight/track_test.exs b/test/tortoise/connection/inflight/track_test.exs deleted file mode 100644 index 4fee2888..00000000 --- a/test/tortoise/connection/inflight/track_test.exs +++ /dev/null @@ -1,163 +0,0 @@ -defmodule Tortoise.Connection.Inflight.TrackTest do - @moduledoc false - use ExUnit.Case - doctest Tortoise.Connection.Inflight.Track - - alias Tortoise.Connection.Inflight.Track - alias Tortoise.Package - - describe "incoming publish" do - test "progress a qos 1 receive" do - id = 0x0001 - publish = %Package.Publish{qos: 1, identifier: id} - - state = Track.create(:positive, publish) - - assert %Track{ - pending: [ - [ - {:dispatch, %Package.Puback{identifier: ^id}}, - :cleanup - ] - ] - } = state - - assert {next_action, resolution} = Track.next(state) - assert {:dispatch, %Package.Puback{identifier: ^id}} = next_action - assert :cleanup = resolution - - assert {:ok, %Track{identifier: ^id, pending: []}} = Track.resolve(state, resolution) - end - - test "progress a qos 2 receive" do - id = 0x0001 - publish = %Package.Publish{qos: 2, identifier: id} - - state = Track.create(:positive, publish) - assert %Track{pending: [[{:dispatch, %Package.Pubrec{}} | _] | _]} = state - - {next_action, resolution} = Track.next(state) - assert {:dispatch, %Package.Pubrec{identifier: ^id}} = next_action - - {:ok, state} = Track.resolve(state, resolution) - # if we send in the same resolution we should not progress - assert {:ok, ^state} = Track.resolve(state, resolution) - - {next_action, resolution} = Track.next(state) - assert {:dispatch, %Package.Pubcomp{identifier: ^id}} = next_action - assert :cleanup = resolution - - assert {:ok, %Track{identifier: ^id, pending: []}} = Track.resolve(state, resolution) - end - end - - describe "outgoing publish" do - test "progress a qos 1 publish" do - id = 0x0001 - publish = %Package.Publish{qos: 1, identifier: id} - caller = {self(), make_ref()} - - state = Track.create({:negative, caller}, publish) - assert %Track{pending: [[{:dispatch, ^publish}, _] | _]} = state - - {next_action, resolution} = Track.next(state) - assert {:dispatch, %Package.Publish{identifier: ^id}} = next_action - assert {:received, %Package.Puback{identifier: ^id}} = resolution - {:ok, state} = Track.resolve(state, resolution) - - # if we send in the same resolution we should not progress - assert {:ok, ^state} = Track.resolve(state, resolution) - - {next_action, resolution} = Track.next(state) - assert {:respond, ^caller} = next_action - assert :cleanup = resolution - {:ok, state} = Track.resolve(state, resolution) - - # if we send in the same resolution we should not progress - assert {:ok, ^state} = Track.resolve(state, resolution) - - assert %Track{identifier: ^id, pending: []} = state - end - - test "progress a qos 2 publish" do - id = 0x0001 - publish = %Package.Publish{qos: 2, identifier: id} - caller = {self(), make_ref()} - - state = Track.create({:negative, caller}, publish) - assert %Track{pending: [[{:dispatch, ^publish}, _] | _]} = state - - {next_action, resolution} = Track.next(state) - assert {:dispatch, %Package.Publish{identifier: ^id}} = next_action - assert {:received, %Package.Pubrec{identifier: ^id}} = resolution - {:ok, state} = Track.resolve(state, resolution) - - # if we send in the same resolution we should not progress - assert {:ok, ^state} = Track.resolve(state, resolution) - - {next_action, resolution} = Track.next(state) - assert {:dispatch, %Package.Pubrel{identifier: ^id}} = next_action - assert {:received, %Package.Pubcomp{identifier: ^id}} = resolution - {:ok, state} = Track.resolve(state, resolution) - - # if we send in the same resolution we should not progress - assert {:ok, ^state} = Track.resolve(state, resolution) - - {next_action, resolution} = Track.next(state) - assert {:respond, ^caller} = next_action - assert :cleanup = resolution - {:ok, state} = Track.resolve(state, resolution) - - # if we send in the same resolution we should not progress - assert {:ok, ^state} = Track.resolve(state, resolution) - - assert %Track{identifier: ^id, pending: []} = state - end - end - - describe "subscriptions" do - test "progress a subscribe" do - id = 0x0001 - subscribe = %Package.Subscribe{identifier: id, topics: [{"foo/bar", 0}]} - suback = %Package.Suback{identifier: id, acks: [ok: 0]} - caller = {self(), make_ref()} - - state = Track.create({:negative, caller}, subscribe) - assert %Track{pending: [[{:dispatch, ^subscribe}, _] | _]} = state - - {next_action, resolution} = Track.next(state) - assert {:dispatch, ^subscribe} = next_action - assert {:received, %Package.Suback{identifier: ^id}} = resolution - {:ok, state} = Track.resolve(state, {:received, suback}) - - {next_action, resolution} = Track.next(state) - assert {:respond, ^caller} = next_action - assert :cleanup = resolution - {:ok, state} = Track.resolve(state, resolution) - - assert %Track{identifier: ^id, pending: []} = state - end - - test "progress an unsubscribe" do - id = 0x0001 - unsubscribe = %Package.Unsubscribe{identifier: id, topics: ["foo/bar"]} - unsuback = %Package.Unsuback{identifier: id} - caller = {self(), make_ref()} - - state = Track.create({:negative, caller}, unsubscribe) - assert %Track{pending: [[{:dispatch, ^unsubscribe}, _] | _]} = state - - {next_action, resolution} = Track.next(state) - assert {:dispatch, ^unsubscribe} = next_action - assert {:received, %Package.Unsuback{identifier: ^id}} = resolution - {:ok, state} = Track.resolve(state, {:received, unsuback}) - - {next_action, resolution} = Track.next(state) - assert {:respond, ^caller} = next_action - assert :cleanup = resolution - {:ok, state} = Track.resolve(state, resolution) - - assert %Track{identifier: ^id, pending: []} = state - end - end -end diff --git a/test/tortoise/connection/inflight_test.exs b/test/tortoise/connection/inflight_test.exs deleted file mode 100644 index 7ff04a1f..00000000 --- a/test/tortoise/connection/inflight_test.exs +++ /dev/null @@ -1,266 +0,0 @@ -defmodule Tortoise.Connection.InflightTest do - use ExUnit.Case, async: true - doctest Tortoise.Connection.Inflight - - alias Tortoise.Package - alias Tortoise.Connection.Inflight - - setup context do - {:ok, %{client_id: context.test}} - end - - def setup_connection(context) do - {:ok, client_socket, server_socket} = Tortoise.Integration.TestTCPTunnel.new() - connection = {Tortoise.Transport.Tcp, client_socket} - key = Tortoise.Registry.via_name(Tortoise.Connection, context.client_id) - Tortoise.Registry.put_meta(key, connection) - Tortoise.Events.dispatch(context.client_id, :connection, connection) - {:ok, Map.merge(context, %{client: client_socket, server: server_socket})} - end - - defp drop_connection(%{server: server} = context) do - :ok = :gen_tcp.close(server) - :ok = Tortoise.Events.dispatch(context.client_id, :status, :down) - {:ok, Map.drop(context, [:client, :server])} - end - - def setup_inflight(context) do - {:ok, pid} = Inflight.start_link(client_id: context.client_id) - {:ok, %{inflight_pid: pid}} - end - - describe "life-cycle" do - setup [:setup_connection] - - test "start/stop", context do - assert {:ok, pid} = Inflight.start_link(client_id: context.client_id) - assert Process.alive?(pid) - assert :ok = Inflight.stop(pid) - refute Process.alive?(pid) - end - end - - describe "Publish with QoS=1" do - setup [:setup_connection, :setup_inflight] - - test "incoming publish QoS=1", %{client_id: client_id} = context do - publish = %Package.Publish{identifier: 1, topic: "foo", qos: 1} - :ok = Inflight.track(client_id, {:incoming, publish}) - assert {:ok, puback} = :gen_tcp.recv(context.server, 0, 500) - assert %Package.Puback{identifier: 1} = Package.decode(puback) - end - - test "outgoing publish QoS=1", %{client_id: client_id} = context do - publish = %Package.Publish{identifier: 1, topic: "foo", qos: 1} - {:ok, ref} = Inflight.track(client_id, {:outgoing, publish}) - assert {:ok, package} = :gen_tcp.recv(context.server, 0, 500) - assert ^publish = Package.decode(package) - - # drop and reestablish the connection - {:ok, context} = drop_connection(context) - {:ok, context} = setup_connection(context) - - # the inflight process should now re-transmit the publish - assert {:ok, package} = :gen_tcp.recv(context.server, 0, 500) - publish = %Package.Publish{publish | dup: true} - assert ^publish = Package.decode(package) - - # simulate that we receive a puback from the server - Inflight.update(client_id, {:received, %Package.Puback{identifier: 1}}) - - # the calling process should get a result response - assert_receive {{Tortoise, ^client_id}, ^ref, :ok} - end - end - - describe "Publish with QoS=2" do - setup [:setup_connection, :setup_inflight] - - test "incoming publish QoS=2", %{client_id: client_id} = context do - publish = %Package.Publish{identifier: 1, topic: "foo", qos: 2} - :ok = Inflight.track(client_id, {:incoming, publish}) - assert {:ok, pubrec} = :gen_tcp.recv(context.server, 0, 500) - assert %Package.Pubrec{identifier: 1} = Package.decode(pubrec) - - # drop and reestablish the connection - {:ok, context} = drop_connection(context) - {:ok, context} = setup_connection(context) - - # now we should receive the same pubrec message - assert {:ok, ^pubrec} = :gen_tcp.recv(context.server, 0, 500) - - # simulate that we receive a pubrel from the server - Inflight.update(client_id, {:received, %Package.Pubrel{identifier: 1}}) - - assert {:ok, pubcomp} = :gen_tcp.recv(context.server, 0, 500) - assert %Package.Pubcomp{identifier: 1} = Package.decode(pubcomp) - end - - test "outgoing publish QoS=2", %{client_id: client_id} = context do - publish = %Package.Publish{identifier: 1, topic: "foo", qos: 2} - {:ok, ref} = Inflight.track(client_id, {:outgoing, publish}) - - # we should transmit the publish - assert {:ok, package} = :gen_tcp.recv(context.server, 0, 500) - assert ^publish = Package.decode(package) - # drop and reestablish the connection - {:ok, context} = drop_connection(context) - {:ok, context} = setup_connection(context) - # the publish should get re-transmitted - publish = %Package.Publish{publish | dup: true} - assert {:ok, package} = :gen_tcp.recv(context.server, 0, 500) - assert ^publish = Package.decode(package) - - # simulate that we receive a pubrel from the server - Inflight.update(client_id, {:received, %Package.Pubrec{identifier: 1}}) - - # we should send the pubrel package - assert {:ok, pubrel} = :gen_tcp.recv(context.server, 0, 500) - assert %Package.Pubrel{identifier: 1} = Package.decode(pubrel) - # drop and reestablish the connection - {:ok, context} = drop_connection(context) - {:ok, context} = setup_connection(context) - # re-transmit the pubrel - assert {:ok, ^pubrel} = :gen_tcp.recv(context.server, 0, 500) - - # When we receive the pubcomp message we should respond the caller - Inflight.update(client_id, {:received, %Package.Pubcomp{identifier: 1}}) - assert_receive {{Tortoise, ^client_id}, ^ref, :ok} - end - end - - describe "Subscription" do - setup [:setup_connection, :setup_inflight] - - test "subscription", %{client_id: client_id} = context do - subscribe = %Package.Subscribe{ - identifier: 1, - topics: [{"foo", 0}, {"bar", 1}, {"baz", 2}] - } - - {:ok, ref} = Inflight.track(client_id, {:outgoing, subscribe}) - - # send the subscribe package - assert {:ok, package} = :gen_tcp.recv(context.server, 0, 500) - assert ^subscribe = Package.decode(package) - # drop and reestablish the connection - {:ok, context} = drop_connection(context) - {:ok, context} = setup_connection(context) - # re-transmit the subscribe package - assert {:ok, ^package} = :gen_tcp.recv(context.server, 0, 500) - - # when receiving the suback we should respond to the caller - suback = %Package.Suback{ - identifier: 1, - acks: [{:ok, 0}, {:ok, 1}, {:ok, 2}] - } - - Inflight.update(client_id, {:received, suback}) - - assert_receive {{Tortoise, ^client_id}, ^ref, _} - end - end - - describe "Unsubscribe" do - setup [:setup_connection, :setup_inflight] - - test "unsubscribe", %{client_id: client_id} = context do - unsubscribe = %Package.Unsubscribe{ - identifier: 1, - topics: ["foo", "bar", "baz"] - } - - {:ok, ref} = Inflight.track(client_id, {:outgoing, unsubscribe}) - - # send the unsubscribe package - assert {:ok, package} = :gen_tcp.recv(context.server, 0, 500) - assert ^unsubscribe = Package.decode(package) - # drop and reestablish the connection - {:ok, context} = drop_connection(context) - {:ok, context} = setup_connection(context) - # re-transmit the subscribe package - assert {:ok, ^package} = :gen_tcp.recv(context.server, 0, 500) - - # when receiving the suback we should respond to the caller - Inflight.update(client_id, {:received, %Package.Unsuback{identifier: 1}}) - - assert_receive {{Tortoise, ^client_id}, ^ref, _} - end - end - - describe "message ordering" do - setup [:setup_connection, :setup_inflight] - - test "publish should be retransmitted in the same order", context do - client_id = context.client_id - publish1 = %Package.Publish{identifier: 250, topic: "foo", qos: 1} - publish2 = %Package.Publish{identifier: 500, topic: "foo", qos: 1} - publish3 = %Package.Publish{identifier: 100, topic: "foo", qos: 1} - - {:ok, _} = Inflight.track(client_id, {:outgoing, publish1}) - {:ok, _} = Inflight.track(client_id, {:outgoing, publish2}) - {:ok, _} = Inflight.track(client_id, {:outgoing, publish3}) - - expected = Package.encode(publish1) |> IO.iodata_to_binary() - assert {:ok, ^expected} = :gen_tcp.recv(context.server, byte_size(expected), 500) - expected = Package.encode(publish2) |> IO.iodata_to_binary() - assert {:ok, ^expected} = :gen_tcp.recv(context.server, byte_size(expected), 500) - expected = Package.encode(publish3) |> IO.iodata_to_binary() - assert {:ok, ^expected} = :gen_tcp.recv(context.server, byte_size(expected), 500) - - # drop and reestablish the connection - {:ok, context} = drop_connection(context) - {:ok, context} = setup_connection(context) - - # the in flight manager should now re-transmit the publish - # messages in the same order they arrived - publish1 = %Package.Publish{publish1 | dup: true} - publish2 = %Package.Publish{publish2 | dup: true} - publish3 = %Package.Publish{publish3 | dup: true} - - expected = Package.encode(publish1) |> IO.iodata_to_binary() - assert {:ok, ^expected} = :gen_tcp.recv(context.server, byte_size(expected), 500) - expected = Package.encode(publish2) |> IO.iodata_to_binary() - assert {:ok, ^expected} = :gen_tcp.recv(context.server, byte_size(expected), 500) - expected = Package.encode(publish3) |> IO.iodata_to_binary() - assert {:ok, ^expected} = :gen_tcp.recv(context.server, byte_size(expected), 500) - end - end - - describe "resetting" do - setup [:setup_connection, :setup_inflight] - - test "cancel outgoing inflight packages", %{client_id: client_id} do - publish = %Package.Publish{identifier: 1, topic: "foo", qos: 1} - {:ok, ref} = Inflight.track(client_id, {:outgoing, publish}) - :ok = Inflight.reset(client_id) - # the calling process should get a result response - assert_receive {{Tortoise, ^client_id}, ^ref, {:error, :canceled}} - end - end - - describe "draining" do - setup [:setup_connection, :setup_inflight] - - test "cancel outgoing inflight packages", %{client_id: client_id} = context do - publish = %Package.Publish{identifier: 1, topic: "foo", qos: 1} - {:ok, ref} = Inflight.track(client_id, {:outgoing, publish}) - # the publish should get dispatched - expected = publish |> Package.encode() |> IO.iodata_to_binary() - assert {:ok, ^expected} = :gen_tcp.recv(context.server, byte_size(expected), 500) - # start draining - :ok = Inflight.drain(client_id) - # updates should have no effect at this point - :ok = Inflight.update(client_id, {:received, %Package.Puback{identifier: 1}}) - # the calling process should get a result response - assert_receive {{Tortoise, ^client_id}, ^ref, {:error, :canceled}} - # Now the inflight manager should be in the draining state, new - # outbound messages should not get accepted - {:ok, ref} = Inflight.track(client_id, {:outgoing, publish}) - assert_receive {{Tortoise, ^client_id}, ^ref, {:error, :terminating}} - # the remote should receive a disconnect package - expected = %Package.Disconnect{} |> Package.encode() |> IO.iodata_to_binary() - assert {:ok, ^expected} = :gen_tcp.recv(context.server, byte_size(expected), 500) - end - end -end diff --git a/test/tortoise/connection/info/capabilities_test.exs b/test/tortoise/connection/info/capabilities_test.exs new file mode 100644 index 00000000..e81e882a --- /dev/null +++ b/test/tortoise/connection/info/capabilities_test.exs @@ -0,0 +1,60 @@ +defmodule Tortoise.Connection.Info.CapabilitiesTest do + use ExUnit.Case, async: true + doctest Tortoise.Connection.Info.Capabilities + + alias Tortoise.Package.Subscribe + alias Tortoise.Connection.Info + + defp create_config(properties) do + # server_keep_alive needs to be set + struct!(%Info.Capabilities{}, properties) + end + + describe "shared_subscription_available: false" do + test "return error if shared subscription is placed" do + no_shared_subscription = create_config(shared_subscription_available: false) + + shared_topic_filter = "$share/foo/bar" + subscribe = %Subscribe{topics: [{shared_topic_filter, qos: 0}]} + + assert {:invalid, reasons} = Info.Capabilities.validate(no_shared_subscription, subscribe) + assert {:shared_subscription_not_available, shared_topic_filter} in reasons + end + end + + describe "wildcard_subscription_available: false" do + test "return error if a subscription with a wildcard is placed" do + no_shared_subscription = create_config(wildcard_subscription_available: false) + + topic_filter_with_single_level_wildcard = "foo/+/bar" + topic_filter_with_multi_level_wildcard = "foo/#" + + subscribe = %Subscribe{ + topics: [ + {topic_filter_with_single_level_wildcard, qos: 0}, + {topic_filter_with_multi_level_wildcard, qos: 0} + ] + } + + assert {:invalid, reasons} = Info.Capabilities.validate(no_shared_subscription, subscribe) + + assert {:wildcard_subscription_not_available, topic_filter_with_single_level_wildcard} in reasons + + assert {:wildcard_subscription_not_available, topic_filter_with_multi_level_wildcard} in reasons + end + end + + describe "subscription_identifier_available: false" do + test "return error if shared subscription is placed" do + config = create_config(subscription_identifiers_available: false) + + subscribe = %Subscribe{ + topics: [{"foo/bar", qos: 0}], + properties: [subscription_identifier: 5] + } + + assert {:invalid, reasons} = Info.Capabilities.validate(config, subscribe) + assert :subscription_identifier_not_available in reasons + end + end +end diff --git a/test/tortoise/connection/receiver_test.exs b/test/tortoise/connection/receiver_test.exs index c45f42b2..4977facc 100644 --- a/test/tortoise/connection/receiver_test.exs +++ b/test/tortoise/connection/receiver_test.exs @@ -1,30 +1,41 @@ defmodule Tortoise.Connection.ReceiverTest do use ExUnit.Case - # use EQC.ExUnit - doctest Tortoise.Connection.Controller + + doctest Tortoise.Connection.Receiver alias Tortoise.Package - alias Tortoise.Connection.{Receiver, Controller} + alias Tortoise.Connection.Receiver + alias Tortoise.Integration.TestTCPTunnel setup context do - {:ok, %{client_id: context.test}} + {:ok, %{session_ref: context.test}} end def setup_receiver(context) do - opts = [client_id: context.client_id] - {:ok, client_socket, server_socket} = Tortoise.Integration.TestTCPTunnel.new() + {:ok, ref, transport} = TestTCPTunnel.new(Tortoise.Transport.Tcp) + + opts = [ + session_ref: context.session_ref, + transport: transport, + parent: self() + ] + {:ok, receiver_pid} = Receiver.start_link(opts) - :ok = Receiver.handle_socket(context.client_id, {Tortoise.Transport.Tcp, client_socket}) - {:ok, %{receiver_pid: receiver_pid, client: client_socket, server: server_socket}} + {:ok, %{connection_ref: ref, transport: transport, receiver_pid: receiver_pid}} end - def setup_controller(context) do - Registry.register(Tortoise.Registry, {Controller, context.client_id}, self()) - :ok + def setup_connection(%{connection_ref: ref} = context) when is_reference(ref) do + {:ok, _connection} = Receiver.connect(context.receiver_pid) + assert_receive {:server_socket, ^ref, server_socket} + {:ok, Map.put(context, :server, server_socket)} + end + + def setup_connection(_) do + raise "run `:setup_receiver/1` before `:setup_connection/1` in the test setup" end describe "receiving" do - setup [:setup_receiver, :setup_controller] + setup [:setup_receiver, :setup_connection] # when a message reached a certain size an issue where the message # got chunked on the connection lead to a crash in the @@ -38,18 +49,18 @@ defmodule Tortoise.Connection.ReceiverTest do :ok = :gen_tcp.send(context.server, Package.encode(package)) - assert_receive {:"$gen_cast", {:incoming, data}} + assert_receive {:incoming, data} assert ^package = Package.decode(data) end test "receive a larger message of about 5000 bytes", context do - # payload = :crypto.strong_rand_bytes(268_435_446) + # payload = :crypto.strong_rand_bytes(268_435_146) payload = :crypto.strong_rand_bytes(5000) package = %Package.Publish{topic: "foo/bar", payload: payload} :ok = :gen_tcp.send(context.server, Package.encode(package)) - assert_receive {:"$gen_cast", {:incoming, data}}, 10000 + assert_receive {:incoming, data}, 10_000 assert ^package = Package.decode(data) end @@ -62,21 +73,20 @@ defmodule Tortoise.Connection.ReceiverTest do :ok = :gen_tcp.send(context.server, <<0b11010000>>) refute_receive {:EXIT, ^receiver_pid, {:protocol_violation, :invalid_header_length}}, 400 :ok = :gen_tcp.send(context.server, <<0>>) - assert_receive {:"$gen_cast", {:incoming, data}}, 10000 + assert_receive {:incoming, data}, 10000 assert %Package.Pingresp{} = Package.decode(data) end end describe "invalid packages" do - setup [:setup_receiver] + setup [:setup_receiver, :setup_connection] - test "invalid header length", context do + test "invalid header length", %{receiver_pid: receiver_pid} = context do Process.flag(:trap_exit, true) - receiver_pid = context.receiver_pid # send too many bytes into the receiver, the header parser # should throw a protocol violation on this :ok = :gen_tcp.send(context.server, <<1, 255, 255, 255, 255, 0>>) - assert_receive {:EXIT, ^receiver_pid, {:protocol_violation, :invalid_header_length}} + assert_receive {:EXIT, ^receiver_pid, {:protocol_violation, :invalid_header_length}}, 5000 end end end diff --git a/test/tortoise/connection_test.exs b/test/tortoise/connection_test.exs index 52cabb71..f6f3a4e8 100644 --- a/test/tortoise/connection_test.exs +++ b/test/tortoise/connection_test.exs @@ -1,12 +1,8 @@ -Code.require_file("../support/scripted_mqtt_server.exs", __DIR__) -Code.require_file("../support/scripted_transport.exs", __DIR__) - defmodule Tortoise.ConnectionTest do use ExUnit.Case, async: true doctest Tortoise.Connection - alias Tortoise.Integration.ScriptedMqttServer - alias Tortoise.Integration.ScriptedTransport + alias Tortoise.Integration.{ScriptedMqttServer, ScriptedTransport} alias Tortoise.Connection alias Tortoise.Package @@ -22,9 +18,9 @@ defmodule Tortoise.ConnectionTest do {:ok, %{client_id: client_id}} end - def setup_scripted_mqtt_server(_context) do + def setup_scripted_mqtt_server(context) do {:ok, pid} = ScriptedMqttServer.start_link() - {:ok, %{scripted_mqtt_server: pid}} + {:ok, Map.put(context, :scripted_mqtt_server, pid)} end def setup_scripted_mqtt_server_ssl(_context) do @@ -46,16 +42,41 @@ defmodule Tortoise.ConnectionTest do }} end + def setup_connection_and_perform_handshake(%{ + client_id: client_id, + scripted_mqtt_server: scripted_mqtt_server + }) do + script = [ + {:receive, %Package.Connect{client_id: client_id}}, + {:send, %Package.Connack{reason: :success, session_present: false}} + ] + + {:ok, {ip, port}} = ScriptedMqttServer.enact(scripted_mqtt_server, script) + + opts = [ + client_id: client_id, + server: {Tortoise.Transport.Tcp, [host: ip, port: port]}, + handler: {TestHandler, [parent: self()]} + ] + + assert {:ok, connection_pid} = Connection.start_link(opts) + + assert_receive {ScriptedMqttServer, {:received, %Package.Connect{}}} + assert_receive {ScriptedMqttServer, :completed} + + {:ok, %{connection_pid: connection_pid}} + end + describe "successful connect" do setup [:setup_scripted_mqtt_server] test "without present state", context do client_id = context.client_id - connect = %Package.Connect{client_id: client_id, clean_session: true} - expected_connack = %Package.Connack{status: :accepted, session_present: false} + connect = %Package.Connect{client_id: client_id, clean_start: true} + expected_connack = %Package.Connack{reason: :success, session_present: false} - script = [{:receive, connect}, {:send, expected_connack}] + script = [{:receive, connect}, {:send, expected_connack}, :pause] {:ok, {ip, port}} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) @@ -65,23 +86,42 @@ defmodule Tortoise.ConnectionTest do handler: {Tortoise.Handler.Default, []} ] - assert {:ok, _pid} = Connection.start_link(opts) + assert {:ok, pid} = Connection.start_link(opts) assert_receive {ScriptedMqttServer, {:received, ^connect}} + assert_receive {ScriptedMqttServer, :paused} + + # Should be able to get a connection when we have connected + assert {:ok, {Tortoise.Transport.Tcp, _port}} = Connection.connection(pid) + + # If the server does not specify a server_keep_alive interval we + # should use the one that was provided in the connect message, + # besides that the values of the config should be the defaults + keep_alive = connect.keep_alive + + assert {:connected, + %Connection.Info{ + keep_alive: ^keep_alive, + capabilities: %Connection.Info.Capabilities{ + server_keep_alive: nil + } + }} = Connection.info(pid) + + send(context.scripted_mqtt_server, :continue) assert_receive {ScriptedMqttServer, :completed} end test "reconnect with present state", context do client_id = context.client_id - connect = %Package.Connect{client_id: client_id, clean_session: true} - reconnect = %Package.Connect{connect | clean_session: false} + connect = %Package.Connect{client_id: client_id, clean_start: true} + reconnect = %Package.Connect{connect | clean_start: false} script = [ {:receive, connect}, - {:send, %Package.Connack{status: :accepted, session_present: false}}, + {:send, %Package.Connack{reason: :success, session_present: false}}, :disconnect, {:receive, reconnect}, - {:send, %Package.Connack{status: :accepted, session_present: true}} + {:send, %Package.Connack{reason: :success, session_present: true}} ] {:ok, {ip, port}} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) @@ -97,12 +137,48 @@ defmodule Tortoise.ConnectionTest do assert_receive {ScriptedMqttServer, {:received, ^reconnect}} assert_receive {ScriptedMqttServer, :completed} end + + test "client should pick the servers keep alive interval if set", context do + client_id = context.client_id + connect = %Package.Connect{client_id: client_id} + server_keep_alive = 0xCAFE + + script = [ + {:receive, connect}, + {:send, + %Package.Connack{reason: :success, properties: [server_keep_alive: server_keep_alive]}}, + :pause + ] + + {:ok, {ip, port}} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + + opts = [ + client_id: client_id, + server: {Tortoise.Transport.Tcp, [host: ip, port: port]}, + handler: {Tortoise.Handler.Default, []} + ] + + assert {:ok, pid} = Connection.start_link(opts) + assert_receive {ScriptedMqttServer, {:received, ^connect}} + + # Should be able to get a connection when we have connected + assert {:ok, {Tortoise.Transport.Tcp, _port}} = Connection.connection(pid) + + # If the server does specify a server_keep_alive interval we + # should use that one for the keep_alive instead of the user + # provided one in the connect message, besides that the values + # of the config should be the defaults + assert {:connected, %{keep_alive: ^server_keep_alive}} = Connection.info(pid) + + send(context.scripted_mqtt_server, :continue) + assert_receive {ScriptedMqttServer, :completed} + end end describe "unsuccessful connect" do setup [:setup_scripted_mqtt_server] - test "unacceptable protocol version", context do + test "unsupported protocol version", context do Process.flag(:trap_exit, true) client_id = context.client_id @@ -110,7 +186,7 @@ defmodule Tortoise.ConnectionTest do script = [ {:receive, connect}, - {:send, %Package.Connack{status: {:refused, :unacceptable_protocol_version}}} + {:send, %Package.Connack{reason: {:refused, :unsupported_protocol_version}}} ] true = Process.unlink(context.scripted_mqtt_server) @@ -126,15 +202,15 @@ defmodule Tortoise.ConnectionTest do assert_receive {ScriptedMqttServer, {:received, ^connect}} assert_receive {ScriptedMqttServer, :completed} - assert_receive {:EXIT, ^pid, {:connection_failed, :unacceptable_protocol_version}} + assert_receive {:EXIT, ^pid, {:connection_failed, :unsupported_protocol_version}} end - test "identifier rejected", context do + test "reject client identifier", context do Process.flag(:trap_exit, true) client_id = context.client_id connect = %Package.Connect{client_id: client_id} - expected_connack = %Package.Connack{status: {:refused, :identifier_rejected}} + expected_connack = %Package.Connack{reason: {:refused, :client_identifier_not_valid}} script = [{:receive, connect}, {:send, expected_connack}] {:ok, {ip, port}} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) @@ -148,7 +224,7 @@ defmodule Tortoise.ConnectionTest do assert {:ok, pid} = Connection.start_link(opts) assert_receive {ScriptedMqttServer, {:received, ^connect}} assert_receive {ScriptedMqttServer, :completed} - assert_receive {:EXIT, ^pid, {:connection_failed, :identifier_rejected}} + assert_receive {:EXIT, ^pid, {:connection_failed, :client_identifier_not_valid}} end test "server unavailable", context do @@ -156,7 +232,7 @@ defmodule Tortoise.ConnectionTest do client_id = context.client_id connect = %Package.Connect{client_id: client_id} - expected_connack = %Package.Connack{status: {:refused, :server_unavailable}} + expected_connack = %Package.Connack{reason: {:refused, :server_unavailable}} script = [{:receive, connect}, {:send, expected_connack}] {:ok, {ip, port}} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) @@ -178,8 +254,7 @@ defmodule Tortoise.ConnectionTest do client_id = context.client_id connect = %Package.Connect{client_id: client_id} - expected_connack = %Package.Connack{status: {:refused, :bad_user_name_or_password}} - + expected_connack = %Package.Connack{reason: {:refused, :bad_user_name_or_password}} script = [{:receive, connect}, {:send, expected_connack}] {:ok, {ip, port}} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) @@ -200,7 +275,7 @@ defmodule Tortoise.ConnectionTest do client_id = context.client_id connect = %Package.Connect{client_id: client_id} - expected_connack = %Package.Connack{status: {:refused, :not_authorized}} + expected_connack = %Package.Connack{reason: {:refused, :not_authorized}} script = [{:receive, connect}, {:send, expected_connack}] {:ok, {ip, port}} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) @@ -220,116 +295,394 @@ defmodule Tortoise.ConnectionTest do end describe "subscriptions" do - setup [:setup_scripted_mqtt_server] + setup [:setup_scripted_mqtt_server, :setup_connection_and_perform_handshake] - test "successful subscription", context do + test "successful subscription", %{connection_pid: connection} = context do client_id = context.client_id - connect = %Package.Connect{client_id: client_id, clean_session: true} - subscription_foo = Enum.into([{"foo", 0}], %Package.Subscribe{identifier: 1}) - subscription_bar = Enum.into([{"bar", 1}], %Package.Subscribe{identifier: 2}) - subscription_baz = Enum.into([{"baz", 2}], %Package.Subscribe{identifier: 3}) + default_subscription_opts = [ + no_local: false, + retain_as_published: false, + retain_handling: 1 + ] + + subscription_foo = + Enum.into( + [{"foo", [{:qos, 0} | default_subscription_opts]}], + %Package.Subscribe{identifier: 1} + ) + + suback_foo = %Package.Suback{identifier: 1, acks: [{:ok, 0}]} + + subscription_bar = + Enum.into( + [{"bar", [{:qos, 1} | default_subscription_opts]}], + %Package.Subscribe{identifier: 2} + ) + + suback_bar = %Package.Suback{identifier: 2, acks: [{:ok, 1}]} + + subscription_baz = + Enum.into( + [{"baz", [{:qos, 2} | default_subscription_opts]}], + %Package.Subscribe{identifier: 3, properties: [user_property: {"foo", "bar"}]} + ) + + suback_baz = %Package.Suback{identifier: 3, acks: [{:ok, 2}]} script = [ - {:receive, connect}, - {:send, %Package.Connack{status: :accepted, session_present: false}}, # subscribe to foo with qos 0 {:receive, subscription_foo}, - {:send, %Package.Suback{identifier: 1, acks: [{:ok, 0}]}}, - # subscribe to bar with qos 0 + {:send, suback_foo}, + # subscribe to bar with qos 1 {:receive, subscription_bar}, - {:send, %Package.Suback{identifier: 2, acks: [{:ok, 1}]}}, + {:send, suback_bar}, + # subscribe to baz with qos 2 {:receive, subscription_baz}, - {:send, %Package.Suback{identifier: 3, acks: [{:ok, 2}]}} - ] - - {:ok, {ip, port}} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) - - opts = [ - client_id: client_id, - server: {Tortoise.Transport.Tcp, [host: ip, port: port]}, - handler: {Tortoise.Handler.Default, []} + {:send, suback_baz} ] - # connection - assert {:ok, _pid} = Connection.start_link(opts) - assert_receive {ScriptedMqttServer, {:received, ^connect}} + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) # subscribe to a foo - :ok = Tortoise.Connection.subscribe_sync(client_id, {"foo", 0}, identifier: 1) + :ok = Tortoise.Connection.subscribe_sync(connection, {"foo", qos: 0}, identifier: 1) assert_receive {ScriptedMqttServer, {:received, ^subscription_foo}} - assert Enum.member?(Tortoise.Connection.subscriptions(client_id), {"foo", 0}) + assert Map.has_key?(Tortoise.Connection.subscriptions(connection), "foo") + assert_receive {{TestHandler, :handle_suback}, {%Package.Subscribe{}, ^suback_foo}} # subscribe to a bar - assert {:ok, ref} = Tortoise.Connection.subscribe(client_id, {"bar", 1}, identifier: 2) - assert_receive {{Tortoise, ^client_id}, ^ref, :ok} + assert {:ok, {^client_id, ref}} = + Tortoise.Connection.subscribe(connection, {"bar", qos: 1}, identifier: 2) + + assert_receive {{Tortoise, ^client_id}, {Package.Suback, ^ref}, :ok} assert_receive {ScriptedMqttServer, {:received, ^subscription_bar}} + assert_receive {{TestHandler, :handle_suback}, {%Package.Subscribe{}, ^suback_bar}} # subscribe to a baz - assert {:ok, ref} = Tortoise.Connection.subscribe(client_id, "baz", qos: 2, identifier: 3) - assert_receive {{Tortoise, ^client_id}, ^ref, :ok} + assert {:ok, {^client_id, ref}} = + Tortoise.Connection.subscribe(connection, "baz", + qos: 2, + identifier: 3, + user_property: {"foo", "bar"} + ) + + assert_receive {{Tortoise, ^client_id}, {Package.Suback, ^ref}, :ok} assert_receive {ScriptedMqttServer, {:received, ^subscription_baz}} + assert_receive {{TestHandler, :handle_suback}, {%Package.Subscribe{}, ^suback_baz}} # foo, bar, and baz should now be in the subscription list - subscriptions = Tortoise.Connection.subscriptions(client_id) - assert Enum.member?(subscriptions, {"foo", 0}) - assert Enum.member?(subscriptions, {"bar", 1}) - assert Enum.member?(subscriptions, {"baz", 2}) + subscriptions = Tortoise.Connection.subscriptions(connection) + assert Map.has_key?(subscriptions, "foo") + assert Map.has_key?(subscriptions, "bar") + assert Map.has_key?(subscriptions, "baz") # done assert_receive {ScriptedMqttServer, :completed} end - test "successful unsubscribe", context do - client_id = context.client_id + # @todo subscribe with a qos but have it accepted with a lower qos + # @todo unsuccessful subscribe - connect = %Package.Connect{client_id: client_id, clean_session: true} + test "successful unsubscribe", %{connection_pid: connection} = context do + client_id = context.client_id unsubscribe_foo = %Package.Unsubscribe{identifier: 2, topics: ["foo"]} - unsubscribe_bar = %Package.Unsubscribe{identifier: 3, topics: ["bar"]} + unsuback_foo = %Package.Unsuback{results: [:success], identifier: 2} + + unsubscribe_bar = %Package.Unsubscribe{ + identifier: 3, + topics: ["bar"], + properties: [user_property: {"foo", "bar"}] + } + + unsuback_bar = %Package.Unsuback{results: [:success], identifier: 3} script = [ - {:receive, connect}, - {:send, %Package.Connack{status: :accepted, session_present: false}}, - {:receive, %Package.Subscribe{topics: [{"foo", 0}, {"bar", 2}], identifier: 1}}, + {:receive, + %Package.Subscribe{ + topics: [ + {"foo", [qos: 0, no_local: false, retain_as_published: false, retain_handling: 1]}, + {"bar", [qos: 2, no_local: false, retain_as_published: false, retain_handling: 1]} + ], + identifier: 1 + }}, {:send, %Package.Suback{acks: [ok: 0, ok: 2], identifier: 1}}, # unsubscribe foo {:receive, unsubscribe_foo}, - {:send, %Package.Unsuback{identifier: 2}}, + {:send, unsuback_foo}, # unsubscribe bar {:receive, unsubscribe_bar}, - {:send, %Package.Unsuback{identifier: 3}} + {:send, unsuback_bar} ] - {:ok, {ip, port}} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) - - subscribe = %Package.Subscribe{topics: [{"foo", 0}, {"bar", 2}], identifier: 1} + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) - opts = [ - client_id: client_id, - server: {Tortoise.Transport.Tcp, [host: ip, port: port]}, - handler: {Tortoise.Handler.Default, []}, - subscriptions: subscribe - ] + subscribe = %Package.Subscribe{ + topics: [ + {"foo", [qos: 0, no_local: false, retain_as_published: false, retain_handling: 1]}, + {"bar", [qos: 2, no_local: false, retain_as_published: false, retain_handling: 1]} + ], + identifier: 1 + } - assert {:ok, _pid} = Connection.start_link(opts) - assert_receive {ScriptedMqttServer, {:received, ^connect}} + {:ok, {^client_id, sub_ref}} = + Tortoise.Connection.subscribe(connection, subscribe.topics, identifier: 1) assert_receive {ScriptedMqttServer, {:received, ^subscribe}} + assert_receive {{TestHandler, :handle_suback}, {_, %Package.Suback{identifier: 1}}} + # now let us try to unsubscribe from foo - :ok = Tortoise.Connection.unsubscribe_sync(client_id, "foo", identifier: 2) + :ok = Tortoise.Connection.unsubscribe_sync(connection, "foo", identifier: 2) assert_receive {ScriptedMqttServer, {:received, ^unsubscribe_foo}} + # handle_unsuback should get called on the callback handler + assert_receive {{TestHandler, :handle_unsuback}, {^unsubscribe_foo, ^unsuback_foo}} - assert %Package.Subscribe{topics: [{"bar", 2}]} = - Tortoise.Connection.subscriptions(client_id) + refute Map.has_key?(Tortoise.Connection.subscriptions(connection), "foo") + # should still have bar in active subscriptions + assert Map.has_key?(Tortoise.Connection.subscriptions(connection), "bar") # and unsubscribe from bar - assert {:ok, ref} = Tortoise.Connection.unsubscribe(client_id, "bar", identifier: 3) - assert_receive {{Tortoise, ^client_id}, ^ref, :ok} + assert {:ok, {^client_id, ref}} = + Tortoise.Connection.unsubscribe(connection, "bar", + identifier: 3, + user_property: {"foo", "bar"} + ) + + assert_receive {{Tortoise, ^client_id}, {Package.Unsuback, ^ref}, :ok} assert_receive {ScriptedMqttServer, {:received, ^unsubscribe_bar}} - assert %Package.Subscribe{topics: []} = Tortoise.Connection.subscriptions(client_id) + # handle_unsuback should get called on the callback handler + assert_receive {{TestHandler, :handle_unsuback}, {^unsubscribe_bar, ^unsuback_bar}} + refute Map.has_key?(Tortoise.Connection.subscriptions(connection), "bar") + # there should be no subscriptions now + assert map_size(Tortoise.Connection.subscriptions(connection)) == 0 assert_receive {ScriptedMqttServer, :completed} + + # the process calling the async subscribe should receive the + # result of the subscribe as a message (suback) + assert_receive {{Tortoise, ^client_id}, {Package.Suback, ^sub_ref}, :ok}, 0 + end + + test "unsuccessful unsubscribe: not authorized", %{connection_pid: connection} = context do + client_id = context.client_id + unsubscribe_foo = %Package.Unsubscribe{identifier: 2, topics: ["foo"]} + unsuback_foo = %Package.Unsuback{results: [error: :not_authorized], identifier: 2} + + script = [ + {:receive, + %Package.Subscribe{ + topics: [ + {"foo", [qos: 0, no_local: false, retain_as_published: false, retain_handling: 1]} + ], + identifier: 1 + }}, + {:send, %Package.Suback{acks: [ok: 0], identifier: 1}}, + # unsubscribe foo + {:receive, unsubscribe_foo}, + {:send, unsuback_foo} + ] + + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + + subscribe = %Package.Subscribe{ + topics: [ + {"foo", [qos: 0, no_local: false, retain_as_published: false, retain_handling: 1]} + ], + identifier: 1 + } + + {:ok, {^client_id, _sub_ref}} = + Tortoise.Connection.subscribe(connection, subscribe.topics, identifier: 1) + + assert_receive {ScriptedMqttServer, {:received, ^subscribe}} + assert_receive {{TestHandler, :handle_suback}, {_, %Package.Suback{identifier: 1}}} + + subscriptions = Tortoise.Connection.subscriptions(connection) + + {:ok, {^client_id, unsub_ref}} = + Tortoise.Connection.unsubscribe(connection, "foo", identifier: 2) + + assert_receive {{Tortoise, ^client_id}, {Package.Unsuback, ^unsub_ref}, :ok} + assert_receive {Tortoise.Integration.ScriptedMqttServer, :completed} + assert ^subscriptions = Tortoise.Connection.subscriptions(connection) + end + + test "unsuccessful unsubscribe: no subscription existed", + %{connection_pid: connection} = context do + client_id = context.client_id + unsubscribe_foo = %Package.Unsubscribe{identifier: 2, topics: ["foo"]} + unsuback_foo = %Package.Unsuback{results: [error: :no_subscription_existed], identifier: 2} + + script = [ + {:receive, + %Package.Subscribe{ + topics: [ + {"foo", [qos: 0, no_local: false, retain_as_published: false, retain_handling: 1]} + ], + identifier: 1 + }}, + {:send, %Package.Suback{acks: [ok: 0], identifier: 1}}, + # unsubscribe foo + {:receive, unsubscribe_foo}, + {:send, unsuback_foo} + ] + + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + + subscribe = %Package.Subscribe{ + topics: [ + {"foo", [qos: 0, no_local: false, retain_as_published: false, retain_handling: 1]} + ], + identifier: 1 + } + + {:ok, {^client_id, _sub_ref}} = + Tortoise.Connection.subscribe(connection, subscribe.topics, identifier: 1) + + assert_receive {ScriptedMqttServer, {:received, ^subscribe}} + assert_receive {{TestHandler, :handle_suback}, {_, %Package.Suback{identifier: 1}}} + + assert Tortoise.Connection.subscriptions(connection) |> Map.has_key?("foo") + + {:ok, {^client_id, unsub_ref}} = + Tortoise.Connection.unsubscribe(connection, "foo", identifier: 2) + + assert_receive {{Tortoise, ^client_id}, {Package.Unsuback, ^unsub_ref}, :ok} + assert_receive {Tortoise.Integration.ScriptedMqttServer, :completed} + # the client should update it state to not include the foo topic + # as the server told us that it is not subscribed + refute Tortoise.Connection.subscriptions(connection) |> Map.has_key?("foo") + end + end + + describe "subscription features" do + setup [:setup_scripted_mqtt_server] + + test "subscribing to a shared topic filter when feature is disabled", context do + # The client should receive an error if it attempt to subscribe + # to a shared topic on a server that does not allow shared + # topics + client_id = context.client_id + + script = [ + {:receive, %Package.Connect{client_id: client_id}}, + {:send, + %Package.Connack{ + reason: :success, + properties: [shared_subscription_available: false] + }} + ] + + {:ok, {ip, port}} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + + assert {:ok, connection} = + Connection.start_link( + client_id: client_id, + server: {Tortoise.Transport.Tcp, [host: ip, port: port]}, + handler: {TestHandler, [parent: self()]} + ) + + assert_receive {ScriptedMqttServer, {:received, %Package.Connect{}}} + + assert {:ok, {Tortoise.Transport.Tcp, _port}} = Connection.connection(connection) + + assert {:connected, + %Connection.Info{ + capabilities: %Connection.Info.Capabilities{ + shared_subscription_available: false + } + }} = Connection.info(connection) + + assert {:error, {:subscription_failure, reasons}} = + Connection.subscribe_sync(connection, {"$share/foo/bar", qos: 0}) + + assert {:shared_subscription_not_available, "$share/foo/bar"} in reasons + end + + test "subscribing to a topic filter with wildcard when feature is disabled", context do + # The client should receive an error if it attempt to subscribe + # to a topic filter containing a wildcard on a server that does + # not allow wildcards in topic filters + client_id = context.client_id + + script = [ + {:receive, %Package.Connect{client_id: client_id}}, + {:send, + %Package.Connack{ + reason: :success, + properties: [wildcard_subscription_available: false] + }} + ] + + {:ok, {ip, port}} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + + assert {:ok, connection_pid} = + Connection.start_link( + client_id: client_id, + server: {Tortoise.Transport.Tcp, [host: ip, port: port]}, + handler: {TestHandler, [parent: self()]} + ) + + assert_receive {ScriptedMqttServer, {:received, %Package.Connect{}}} + + assert {:ok, {Tortoise.Transport.Tcp, _port}} = Connection.connection(connection_pid) + + assert {:connected, %{capabilities: %{wildcard_subscription_available: false}}} = + Connection.info(connection_pid) + + assert {:error, {:subscription_failure, reasons}} = + Connection.subscribe_sync(connection_pid, {"foo/+/bar", qos: 0}) + + assert {:wildcard_subscription_not_available, "foo/+/bar"} in reasons + + assert {:error, {:subscription_failure, reasons}} = + Connection.subscribe_sync(connection_pid, {"foo/#", qos: 0}) + + assert {:wildcard_subscription_not_available, "foo/#"} in reasons + end + + test "subscribing with a subscription identifier when feature is disabled", context do + # The client should receive an error if it attempt to subscribe + # to a topic filter and specifying a subscription identifier on + # a server that does not allow subscription identifiers in topic + # filters + client_id = context.client_id + + script = [ + {:receive, %Package.Connect{client_id: client_id}}, + {:send, + %Package.Connack{ + reason: :success, + properties: [subscription_identifiers_available: false] + }} + ] + + {:ok, {ip, port}} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + + assert {:ok, connection_pid} = + Connection.start_link( + client_id: client_id, + server: {Tortoise.Transport.Tcp, [host: ip, port: port]}, + handler: {TestHandler, [parent: self()]} + ) + + assert_receive {ScriptedMqttServer, {:received, %Package.Connect{}}} + + assert {:ok, {Tortoise.Transport.Tcp, _port}} = Connection.connection(connection_pid) + + assert {:connected, + %Connection.Info{ + capabilities: %Connection.Info.Capabilities{ + subscription_identifiers_available: false + } + }} = Connection.info(connection_pid) + + assert {:error, {:subscription_failure, reasons}} = + Connection.subscribe_sync(connection_pid, {"foo/+/bar", qos: 0}, + subscription_identifier: 5 + ) + + assert :subscription_identifier_not_available in reasons end end @@ -339,8 +692,8 @@ defmodule Tortoise.ConnectionTest do test "successful connect", context do client_id = context.client_id - connect = %Package.Connect{client_id: client_id, clean_session: true} - expected_connack = %Package.Connack{status: :accepted, session_present: false} + connect = %Package.Connect{client_id: client_id, clean_start: true} + expected_connack = %Package.Connack{reason: :success, session_present: false} script = [{:receive, connect}, {:send, expected_connack}] {:ok, {ip, port}} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) @@ -369,8 +722,8 @@ defmodule Tortoise.ConnectionTest do test "successful connect (no certificate verification)", context do client_id = context.client_id - connect = %Package.Connect{client_id: client_id, clean_session: true} - expected_connack = %Package.Connack{status: :accepted, session_present: false} + connect = %Package.Connect{client_id: client_id, clean_start: true} + expected_connack = %Package.Connack{reason: :success, session_present: false} script = [{:receive, connect}, {:send, expected_connack}] {:ok, {ip, port}} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) @@ -424,8 +777,8 @@ defmodule Tortoise.ConnectionTest do test "nxdomain", context do client_id = context.client_id - connect = %Package.Connect{client_id: client_id, clean_session: true} - expected_connack = %Package.Connack{status: :accepted, session_present: false} + connect = %Package.Connect{client_id: client_id, clean_start: true} + expected_connack = %Package.Connack{reason: :success, session_present: false} refusal = {:error, :nxdomain} {:ok, _} = @@ -460,10 +813,9 @@ defmodule Tortoise.ConnectionTest do # with `{:error, :econnrefused}`, and then it will finally start # accepting connections Process.flag(:trap_exit, true) - client_id = context.client_id - connect = %Package.Connect{client_id: client_id, clean_session: true} - expected_connack = %Package.Connack{status: :accepted, session_present: false} + connect = %Package.Connect{client_id: context.client_id, clean_start: true} + expected_connack = %Package.Connack{reason: :success, session_present: false} refusal = {:error, :econnrefused} {:ok, _pid} = @@ -478,14 +830,14 @@ defmodule Tortoise.ConnectionTest do {:refute_connection, refusal}, {:refute_connection, refusal}, # finally start accepting connections again - {:expect, %Package.Connect{connect | clean_session: false}}, + {:expect, %Package.Connect{connect | clean_start: false}}, {:dispatch, expected_connack} ] ) assert {:ok, _pid} = Tortoise.Connection.start_link( - client_id: client_id, + client_id: context.client_id, server: {ScriptedTransport, host: 'localhost', port: 1883}, backoff: [min_interval: 0], handler: {Tortoise.Handler.Logger, []} @@ -503,7 +855,7 @@ defmodule Tortoise.ConnectionTest do Process.flag(:trap_exit, true) client_id = context.client_id - connect = %Package.Connect{client_id: client_id, clean_session: true} + connect = %Package.Connect{client_id: client_id, clean_start: true} {:ok, _pid} = ScriptedTransport.start_link( @@ -523,8 +875,9 @@ defmodule Tortoise.ConnectionTest do assert_receive {ScriptedTransport, :connected} assert_receive {ScriptedTransport, {:received, %Package.Connect{}}} + assert_receive {:EXIT, ^pid, {:protocol_violation, violation}} - assert %{expected: Tortoise.Package.Connect, got: _} = violation + assert %{expected: [Tortoise.Package.Connack, Tortoise.Package.Auth], got: _} = violation assert_receive {ScriptedTransport, :completed} end end @@ -532,55 +885,58 @@ defmodule Tortoise.ConnectionTest do describe "socket subscription" do setup [:setup_scripted_mqtt_server] - test "return error if asking for a connection on an non-existent connection", context do - assert {:error, :unknown_connection} = Connection.connection(context.client_id) + test "return error if asking for a connection on an non-existent connection" do + {pid, ref} = spawn_monitor(fn -> nil end) + assert_receive {:DOWN, ^ref, :process, ^pid, :normal} + assert {:error, :unknown_connection} = Connection.connection(pid) end test "receive a socket from a connection", context do - client_id = context.client_id - - connect = %Package.Connect{client_id: client_id, clean_session: true} - expected_connack = %Package.Connack{status: :accepted, session_present: false} + connect = %Package.Connect{client_id: context.client_id, clean_start: true} + expected_connack = %Package.Connack{reason: :success, session_present: false} script = [{:receive, connect}, {:send, expected_connack}] {:ok, {ip, port}} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) opts = [ - client_id: client_id, + client_id: context.client_id, server: {Tortoise.Transport.Tcp, [host: ip, port: port]}, handler: {Tortoise.Handler.Default, []} ] - assert {:ok, _pid} = Connection.start_link(opts) + assert {:ok, connection} = Connection.start_link(opts) assert_receive {ScriptedMqttServer, {:received, ^connect}} assert {:ok, {Tortoise.Transport.Tcp, _socket}} = - Connection.connection(client_id, timeout: 500) + Connection.connection(connection, timeout: 500) assert_receive {ScriptedMqttServer, :completed} end test "timeout on a socket from a connection", context do - client_id = context.client_id - - connect = %Package.Connect{client_id: client_id, clean_session: true} + connect = %Package.Connect{client_id: context.client_id, clean_start: true} script = [{:receive, connect}, :pause] {:ok, {ip, port}} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) opts = [ - client_id: client_id, + client_id: context.client_id, server: {Tortoise.Transport.Tcp, [host: ip, port: port]}, handler: {Tortoise.Handler.Default, []} ] - assert {:ok, _pid} = Connection.start_link(opts) + assert {:ok, connection} = Connection.start_link(opts) assert_receive {ScriptedMqttServer, {:received, ^connect}} assert_receive {ScriptedMqttServer, :paused} - assert {:error, :timeout} = Connection.connection(client_id, timeout: 5) + {child_pid, mon_ref} = + spawn_monitor(fn -> + Connection.connection(connection, timeout: 5) + end) + + assert_receive {:DOWN, ^mon_ref, :process, ^child_pid, {:timeout, _}} send(context.scripted_mqtt_server, :continue) assert_receive {ScriptedMqttServer, :completed} @@ -595,27 +951,781 @@ defmodule Tortoise.ConnectionTest do client_id = context.client_id connect = %Package.Connect{client_id: client_id} - expected_connack = %Package.Connack{status: :accepted, session_present: false} + expected_connack = %Package.Connack{reason: :success, session_present: false} disconnect = %Package.Disconnect{} script = [{:receive, connect}, {:send, expected_connack}, {:receive, disconnect}] {:ok, {ip, port}} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + handler = + {TestHandler, + [ + parent: self(), + status_change: fn status, state -> + send(state.parent, {{TestHandler, :status_change}, status}) + + fun = fn _ -> + send(state.parent, :from_connection_callback) + end + + {:cont, state, [{:eval, fun}]} + end + ]} + opts = [ client_id: client_id, server: {Tortoise.Transport.Tcp, [host: ip, port: port]}, - handler: {Tortoise.Handler.Default, []} + handler: handler ] - assert {:ok, pid} = Connection.start_link(opts) + assert {:ok, connection_pid} = Connection.start_link(opts) assert_receive {ScriptedMqttServer, {:received, ^connect}} - assert :ok = Tortoise.Connection.disconnect(client_id) + {:ok, {Tortoise.Transport.Tcp, _}} = Connection.connection(connection_pid) + + {:connected, + %{ + receiver_pid: receiver_pid + }} = Connection.info(connection_pid) + + receiver_mon = Process.monitor(receiver_pid) + + assert :ok = Tortoise.Connection.disconnect(connection_pid) + + assert_receive {ScriptedMqttServer, {:received, ^disconnect}} + assert_receive {:EXIT, ^connection_pid, :shutdown} + + assert_receive {ScriptedMqttServer, :completed} + + # make sure the transmitter terminates as well + assert_receive {:DOWN, ^receiver_mon, :process, ^receiver_pid, :normal} + + # The user defined handler should have the following callbacks + # triggered during this exchange + {handler_mod, handler_init_opts} = handler + assert_receive {{^handler_mod, :init}, ^handler_init_opts} + assert_receive {{^handler_mod, :status_change}, :up} + assert_receive {{^handler_mod, :terminate}, :shutdown} + assert_receive {{^handler_mod, :handle_connack}, %Package.Connack{}} + refute_receive {{^handler_mod, _}, _} + + # make sure user defined next actions works for the connection + # callback + assert_receive :from_connection_callback + end + + test "user next actions", context do + Process.flag(:trap_exit, true) + client_id = context.client_id + connect = %Package.Connect{client_id: client_id} + expected_connack = %Package.Connack{reason: :success, session_present: false} + + subscribe = %Package.Subscribe{ + identifier: 1, + properties: [], + topics: [ + {"foo/bar", [qos: 1, no_local: false, retain_as_published: false, retain_handling: 1]} + ] + } + + unsubscribe = %Package.Unsubscribe{ + identifier: 2, + properties: [], + topics: ["foo/bar"] + } + + suback = %Package.Suback{identifier: 1, acks: [{:ok, 0}]} + unsuback = %Package.Unsuback{identifier: 2, results: [:success]} + disconnect = %Package.Disconnect{} + + script = [ + {:receive, connect}, + {:send, expected_connack}, + {:receive, subscribe}, + {:send, suback}, + {:receive, unsubscribe}, + {:send, unsuback}, + {:receive, disconnect} + ] + + {:ok, {ip, port}} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + + # the handler will contain a handle connack that will continue + # and setup a subscribe command; this should result in the + # server receiving a subscribe package. + handler = + {TestHandler, + [ + parent: self(), + handle_connack: fn %Package.Connack{reason: :success}, state -> + {:cont, state, [{:subscribe, "foo/bar", qos: 1, identifier: 1}]} + end, + handle_suback: fn %Package.Subscribe{}, %Package.Suback{}, state -> + {:cont, state, [{:unsubscribe, "foo/bar", identifier: 2}]} + end, + handle_unsuback: fn %Package.Unsubscribe{}, %Package.Unsuback{}, state -> + {:cont, state, [:disconnect]} + end, + terminate: fn reason, %{parent: parent} -> + send(parent, {self(), {:terminating, reason}}) + :ok + end + ]} + + opts = [ + client_id: client_id, + server: {Tortoise.Transport.Tcp, [host: ip, port: port]}, + handler: handler + ] + + assert {:ok, pid} = Connection.start_link(opts) + assert_receive {ScriptedMqttServer, {:received, ^connect}} + # the handle_connack will setup a subscribe command; tortoise + # should subscribe to the topic + assert_receive {ScriptedMqttServer, {:received, ^subscribe}} + # the handle_suback should generate an unsubscribe command + assert_receive {ScriptedMqttServer, {:received, ^unsubscribe}} + # the handle_unsuback callback should generate a disconnect + # command, which should disconnect the client from the server; + # which will receive the disconnect message, and the client + # process should terminate and exit assert_receive {ScriptedMqttServer, {:received, ^disconnect}} - assert_receive {:EXIT, ^pid, :shutdown} + assert_receive {^pid, {:terminating, :normal}} + + # all done + assert_receive {ScriptedMqttServer, :completed} + end + end + + describe "ping" do + setup [:setup_scripted_mqtt_server, :setup_connection_and_perform_handshake] + + test "send pingreq and receive a pingresp", context do + client_id = context.client_id + ping_request = %Package.Pingreq{} + expected_pingresp = %Package.Pingresp{} + script = [{:receive, ping_request}, {:send, expected_pingresp}] + + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + assert_receive {{TestHandler, :handle_connack}, %Tortoise.Package.Connack{}} + + {:ok, {^client_id, ref}} = Connection.ping(context.connection_pid) + assert_receive {ScriptedMqttServer, {:received, ^ping_request}} + assert_receive {{Tortoise, ^client_id}, {Package.Pingreq, ^ref}, _} + assert_receive {ScriptedMqttServer, :completed} + end + + test "ping_sync/2", context do + ping_request = %Package.Pingreq{} + expected_pingresp = %Package.Pingresp{} + script = [{:receive, ping_request}, {:send, expected_pingresp}] + + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + + # make sure the client is connected + assert_receive {{TestHandler, :handle_connack}, %Tortoise.Package.Connack{}} + + {parent, ref} = {self(), make_ref()} + + spawn_link(fn -> + ping_res = Connection.ping_sync(context.connection_pid) + send(parent, {{:child_result, ref}, ping_res}) + end) + + assert_receive {ScriptedMqttServer, {:received, ^ping_request}} + assert_receive {{:child_result, ^ref}, {:ok, time}} + assert_receive {ScriptedMqttServer, :completed} + end + end + + describe "Protocol violations" do + setup [:setup_scripted_mqtt_server, :setup_connection_and_perform_handshake] + + test "Receiving a connect from the server is a protocol violation", context do + Process.flag(:trap_exit, true) + unexpected_connect = %Package.Connect{client_id: "foo"} + script = [{:send, unexpected_connect}] + + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + pid = context.connection_pid + expected_reason = {:protocol_violation, {:unexpected_package, unexpected_connect}} + assert_receive {:EXIT, ^pid, ^expected_reason} + + # the terminate/2 callback should get triggered + assert_receive {{TestHandler, :terminate}, ^expected_reason} + end + + test "Receiving a connack after the handshake is a protocol violation", context do + Process.flag(:trap_exit, true) + unexpected_connack = %Package.Connack{reason: :success} + script = [{:send, unexpected_connack}] + + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + pid = context.connection_pid + expected_reason = {:protocol_violation, {:unexpected_package, unexpected_connack}} + assert_receive {:EXIT, ^pid, ^expected_reason} + + # the terminate/2 callback should get triggered + assert_receive {{TestHandler, :terminate}, ^expected_reason} + end + + test "Receiving a ping request from the server is a protocol violation", context do + Process.flag(:trap_exit, true) + unexpected_pingreq = %Package.Pingreq{} + script = [{:send, unexpected_pingreq}] + + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + pid = context.connection_pid + expected_reason = {:protocol_violation, {:unexpected_package, unexpected_pingreq}} + assert_receive {:EXIT, ^pid, ^expected_reason} + + # the terminate/2 callback should get triggered + assert_receive {{TestHandler, :terminate}, ^expected_reason} + end + + test "Receiving a subscribe package from the server is a protocol violation", context do + Process.flag(:trap_exit, true) + + unexpected_subscribe = %Package.Subscribe{ + topics: [ + {"foo/bar", [qos: 0, no_local: false, retain_as_published: false, retain_handling: 1]} + ], + identifier: 1 + } + + script = [{:send, unexpected_subscribe}] + + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + pid = context.connection_pid + expected_reason = {:protocol_violation, {:unexpected_package, unexpected_subscribe}} + assert_receive {:EXIT, ^pid, ^expected_reason} + + # the terminate/2 callback should get triggered + assert_receive {{TestHandler, :terminate}, ^expected_reason} + end + + test "Receiving an unsubscribe package from the server is a protocol violation", context do + Process.flag(:trap_exit, true) + + unexpected_unsubscribe = %Package.Unsubscribe{ + topics: ["foo/bar"], + identifier: 1 + } + + script = [{:send, unexpected_unsubscribe}] + + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + pid = context.connection_pid + expected_reason = {:protocol_violation, {:unexpected_package, unexpected_unsubscribe}} + assert_receive {:EXIT, ^pid, ^expected_reason} + + # the terminate/2 callback should get triggered + assert_receive {{TestHandler, :terminate}, ^expected_reason} + end + end + + describe "Publish with QoS=0" do + # , :setup_connection_and_perform_handshake + setup [:setup_scripted_mqtt_server] + + test "Receiving a publish", context do + Process.flag(:trap_exit, true) + + publish = %Package.Publish{topic: "foo/bar", qos: 0} + + callbacks = [ + handle_publish: fn topic, %Package.Publish{}, %{parent: parent} = state -> + send(parent, {{TestHandler, :handle_publish}, publish}) + send(parent, {{TestHandler, :altered_topic}, topic}) + fun = fn _ -> send(parent, {TestHandler, :next_action_triggered}) end + {:cont, state, [{:eval, fun}]} + end + ] + + {:ok, context} = connect_and_perform_handshake(context, callbacks) + + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, [{:send, publish}]) + pid = context.connection_pid + + refute_receive {:EXIT, ^pid, {:protocol_violation, {:unexpected_package, ^publish}}} + assert_receive {ScriptedMqttServer, :completed} + + # the handle publish callback should have been called + assert_receive {{TestHandler, :handle_publish}, ^publish} + expected_topic_list = ["foo", "bar"] + assert_receive {{TestHandler, :altered_topic}, ^expected_topic_list} + assert_receive {TestHandler, :next_action_triggered} + end + end + + describe "Publish with QoS=1" do + setup [:setup_scripted_mqtt_server, :setup_connection_and_perform_handshake] + + test "incoming publish with QoS=1", context do + Process.flag(:trap_exit, true) + + publish = + %Package.Publish{identifier: 1, topic: "foo/bar", qos: 1} + |> Package.Meta.infer() + + expected_puback = %Package.Puback{identifier: 1} + + script = [ + {:send, publish}, + {:receive, expected_puback} + ] + + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + assert_receive {ScriptedMqttServer, :completed} + + # the handle publish callback should have been called + assert_receive {{TestHandler, :handle_publish}, ^publish} + end + + test "outgoing publish with QoS=1", %{client_id: client_id} = context do + Process.flag(:trap_exit, true) + + publish = + %Package.Publish{identifier: 1, topic: "foo/bar", qos: 1} + |> Package.Meta.infer() + + puback = %Package.Puback{identifier: 1} + + script = [ + {:receive, publish}, + {:send, puback} + ] + + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + pid = context.connection_pid + + assert {:ok, {^client_id, ref}} = Tortoise.Connection.publish(pid, publish) + + refute_receive {:EXIT, ^pid, {:protocol_violation, {:unexpected_package, _}}} + assert_receive {ScriptedMqttServer, {:received, ^publish}} + assert_receive {ScriptedMqttServer, :completed} + # the caller should receive an :ok for the ref when it is published + assert_receive {{Tortoise, ^client_id}, {Package.Publish, ^ref}, :ok} + end + + test "outgoing publish with QoS=1 (sync call)", context do + Process.flag(:trap_exit, true) + + publish = + %Package.Publish{identifier: 1, topic: "foo/bar", qos: 1} + |> Package.Meta.infer() + + puback = %Package.Puback{identifier: 1} + + script = [ + {:receive, publish}, + {:send, puback} + ] + + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + + # setup a blocking call + {parent, test_ref} = {self(), make_ref()} + pid = context.connection_pid + + spawn_link(fn -> + test_result = Tortoise.Connection.publish_sync(pid, publish) + send(parent, {:sync_call_result, test_ref, test_result}) + end) + + refute_receive {:EXIT, ^pid, {:protocol_violation, {:unexpected_package, _}}} + assert_receive {ScriptedMqttServer, {:received, ^publish}} + assert_receive {ScriptedMqttServer, :completed} + # the caller should receive an :ok for the ref when it is published + assert_receive {:sync_call_result, ^test_ref, :ok} + end + end + + describe "next actions" do + setup [:setup_scripted_mqtt_server] + + test "Handling next actions from handle_puback callback", context do + Process.flag(:trap_exit, true) + scripted_mqtt_server = context.scripted_mqtt_server + client_id = context.client_id + + script = [ + {:receive, %Package.Connect{client_id: client_id}}, + {:send, %Package.Connack{reason: :success, session_present: false}} + ] + + {:ok, {ip, port}} = ScriptedMqttServer.enact(scripted_mqtt_server, script) + + handler_opts = [ + parent: self(), + handle_puback: fn %Package.Puback{}, state -> + {:cont, state, [{:subscribe, "foo/bar", qos: 0, identifier: 2}]} + end + ] + + opts = [ + client_id: client_id, + server: {Tortoise.Transport.Tcp, [host: ip, port: port]}, + handler: {TestHandler, handler_opts} + ] + + assert {:ok, connection_pid} = Connection.start_link(opts) + + assert_receive {ScriptedMqttServer, {:received, %Package.Connect{}}} + assert_receive {ScriptedMqttServer, :completed} + + publish = + %Package.Publish{identifier: 1, topic: "foo/bar", qos: 1} + |> Package.Meta.infer() + + puback = %Package.Puback{identifier: 1} + + default_subscription_opts = [ + no_local: false, + retain_as_published: false, + retain_handling: 1 + ] + subscribe = + Enum.into( + [{"foo/bar", [{:qos, 0} | default_subscription_opts]}], + %Package.Subscribe{identifier: 2} + ) + + suback = %Package.Suback{identifier: 2, acks: [{:ok, 0}]} + + script = [ + {:receive, publish}, + {:send, puback}, + {:receive, subscribe}, + {:send, suback} + ] + + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + pid = connection_pid + + assert {:ok, {^client_id, ref}} = Tortoise.Connection.publish(pid, publish) + + refute_receive {:EXIT, ^pid, {:protocol_violation, {:unexpected_package, _}}} + assert_receive {ScriptedMqttServer, {:received, ^publish}} + assert_receive {ScriptedMqttServer, {:received, ^subscribe}} + assert_receive {ScriptedMqttServer, :completed} + # the caller should receive an :ok for the ref when it is published + assert_receive {{Tortoise, ^client_id}, {Package.Publish, ^ref}, :ok} + assert_receive {{TestHandler, :handle_suback}, {_subscribe, _suback}} + end + + test "Handling next actions from handle_pubrel and handle_pubcomp callback", context do + Process.flag(:trap_exit, true) + scripted_mqtt_server = context.scripted_mqtt_server + client_id = context.client_id + expected_connack = %Package.Connack{reason: :success, session_present: false} + + script = [ + {:receive, %Package.Connect{client_id: client_id}}, + {:send, expected_connack} + ] + + {:ok, {ip, port}} = ScriptedMqttServer.enact(scripted_mqtt_server, script) + + test_process = self() + + send_back = fn package -> + fn _state -> + send(test_process, {:next_action_from, package}) + end + end + + handler_opts = [ + parent: self(), + handle_connack: fn package, state -> + {:cont, state, [{:eval, send_back.(package)}]} + end, + handle_pubrec: fn package, state -> + {:cont, state, [{:eval, send_back.(package)}]} + end, + handle_pubcomp: fn package, state -> + {:cont, state, [{:eval, send_back.(package)}]} + end + ] + + opts = [ + client_id: client_id, + server: {Tortoise.Transport.Tcp, [host: ip, port: port]}, + handler: {TestHandler, handler_opts} + ] + + assert {:ok, connection_pid} = Connection.start_link(opts) + assert_receive {ScriptedMqttServer, {:received, %Package.Connect{}}} + assert_receive {ScriptedMqttServer, :completed} + + publish = + %Package.Publish{identifier: 1, topic: "foo/bar", qos: 2} + |> Package.Meta.infer() + + pubrec = %Package.Pubrec{identifier: 1} + pubrel = %Package.Pubrel{identifier: 1} + pubcomp = %Package.Pubcomp{identifier: 1} + + script = [ + {:receive, publish}, + {:send, pubrec}, + {:receive, pubrel}, + {:send, pubcomp} + ] + + {:ok, _} = ScriptedMqttServer.enact(scripted_mqtt_server, script) + + assert {:ok, ref} = Tortoise.Connection.publish(connection_pid, publish) + + assert_receive {ScriptedMqttServer, :completed} + assert_receive {ScriptedMqttServer, {:received, %Package.Publish{}}} + assert_receive {ScriptedMqttServer, {:received, %Package.Pubrel{}}} + + assert_receive {:next_action_from, ^expected_connack} + assert_receive {:next_action_from, ^pubrec} + assert_receive {:next_action_from, ^pubcomp} + end + end + + describe "Publish with QoS=2" do + setup [:setup_scripted_mqtt_server, :setup_connection_and_perform_handshake] + + test "incoming publish with QoS=2", context do + Process.flag(:trap_exit, true) + + publish = + %Package.Publish{identifier: 1, topic: "foo/bar", qos: 2} + |> Package.Meta.infer() + + expected_pubrec = %Package.Pubrec{identifier: 1} + pubrel = %Package.Pubrel{identifier: 1} + expected_pubcomp = %Package.Pubcomp{identifier: 1} + + script = [ + {:send, publish}, + {:receive, expected_pubrec}, + {:send, pubrel}, + {:receive, expected_pubcomp} + ] + + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + pid = context.connection_pid + + refute_receive {:EXIT, ^pid, {:protocol_violation, {:unexpected_package, _}}} + assert_receive {ScriptedMqttServer, :completed} + + # the handle publish, and handle_pubrel callbacks should have been called + assert_receive {{TestHandler, :handle_pubrel}, ^pubrel} + assert_receive {{TestHandler, :handle_publish}, ^publish} + end + + @tag skip: true + test "incoming publish with QoS=2 with duplicate", context do + Process.flag(:trap_exit, true) + + publish = + %Package.Publish{identifier: 1, topic: "foo/bar", qos: 2} + |> Package.Meta.infer() + + dup_publish = %Package.Publish{publish | dup: true} + expected_pubrec = %Package.Pubrec{identifier: 1} + pubrel = %Package.Pubrel{identifier: 1} + expected_pubcomp = %Package.Pubcomp{identifier: 1} + + script = [ + {:send, publish}, + {:send, dup_publish}, + {:receive, expected_pubrec}, + {:send, pubrel}, + {:receive, expected_pubcomp} + ] + + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + pid = context.connection_pid + + refute_receive {:EXIT, ^pid, {:protocol_violation, {:unexpected_package, _}}} + assert_receive {ScriptedMqttServer, :completed} + + # the handle publish, and handle_pubrel callbacks should have been called + assert_receive {{TestHandler, :handle_pubrel}, ^pubrel} + assert_receive {{TestHandler, :handle_publish}, ^publish} + # the handle publish should only get called once, so if the + # duplicated publish result in a handle_publish message it would + # be a failure. + refute_receive {{TestHandler, :handle_publish}, ^dup_publish} + end + + @tag skip: true + test "incoming publish with QoS=2 with first message marked as duplicate", context do + Process.flag(:trap_exit, true) + + publish = + %Package.Publish{identifier: 1, topic: "foo/bar", qos: 2, dup: true} + |> Package.Meta.infer() + + expected_pubrec = %Package.Pubrec{identifier: 1} + pubrel = %Package.Pubrel{identifier: 1} + expected_pubcomp = %Package.Pubcomp{identifier: 1} + + script = [ + {:send, publish}, + {:receive, expected_pubrec}, + {:send, pubrel}, + {:receive, expected_pubcomp} + ] + + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + pid = context.connection_pid + + refute_receive {:EXIT, ^pid, {:protocol_violation, {:unexpected_package, _}}} + assert_receive {ScriptedMqttServer, :completed} + + # the handle publish, and handle_pubrel callbacks should have + # been called; we convert the dup:true package to dup:false if + # it is the first message we see with that id + assert_receive {{TestHandler, :handle_pubrel}, ^pubrel} + non_dup_publish = %Package.Publish{publish | dup: false} + assert_receive {{TestHandler, :handle_publish}, ^non_dup_publish} + end + + test "outgoing publish with QoS=2", %{client_id: client_id} = context do + Process.flag(:trap_exit, true) + + publish = + %Package.Publish{identifier: 1, topic: "foo/bar", qos: 2} + |> Package.Meta.infer() + + pubrec = %Package.Pubrec{identifier: 1} + pubrel = %Package.Pubrel{identifier: 1} + pubcomp = %Package.Pubcomp{identifier: 1} + + script = [ + {:receive, publish}, + {:send, pubrec}, + {:receive, pubrel}, + {:send, pubcomp} + ] + + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + pid = context.connection_pid + + assert {:ok, {^client_id, ref}} = Tortoise.Connection.publish(pid, publish) + + refute_receive {:EXIT, ^pid, {:protocol_violation, {:unexpected_package, _}}} + assert_receive {ScriptedMqttServer, {:received, ^publish}} + assert_receive {ScriptedMqttServer, {:received, ^pubrel}} + assert_receive {ScriptedMqttServer, :completed} + assert_receive {{Tortoise, ^client_id}, {Package.Publish, ^ref}, :ok} + + # the handle_pubrec callback should have been called + assert_receive {{TestHandler, :handle_pubrec}, ^pubrec} + assert_receive {{TestHandler, :handle_pubcomp}, ^pubcomp} + end + end + + describe "Disconnect" do + setup [:setup_scripted_mqtt_server] + + # [x] :normal_disconnection + # [ ] :unspecified_error + # [ ] :malformed_packet + # [ ] :protocol_error + # [ ] :implementation_specific_error + # [ ] :not_authorized + # [ ] :server_busy + # [ ] :server_shutting_down + # [ ] :keep_alive_timeout + # [ ] :session_taken_over + # [ ] :topic_filter_invalid + # [ ] :topic_name_invalid + # [ ] :receive_maximum_exceeded + # [ ] :topic_alias_invalid + # [ ] :packet_too_large + # [ ] :message_rate_too_high + # [ ] :quota_exceeded + # [ ] :administrative_action + # [ ] :payload_format_invalid + # [ ] :retain_not_supported + # [ ] :qos_not_supported + # [ ] :use_another_server (has :server_reference in properties) + # [ ] :server_moved (has :server_reference in properties) + # [ ] :shared_subscriptions_not_supported + # [ ] :connection_rate_exceeded + # [ ] :maximum_connect_time + # [ ] :subscription_identifiers_not_supported + # [ ] :wildcard_subscriptions_not_supported + + test "normal disconnection", context do + Process.flag(:trap_exit, true) + disconnect = %Package.Disconnect{reason: :normal_disconnection} + + callbacks = [ + handle_disconnect: fn %Package.Disconnect{} = disconnect, state -> + send(state.parent, {{TestHandler, :handle_disconnect}, disconnect}) + {:stop, :normal, state} + end + ] + + {:ok, %{connection_pid: pid} = context} = connect_and_perform_handshake(context, callbacks) + + script = [{:send, disconnect}] + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + + refute_receive {:EXIT, ^pid, {:protocol_violation, {:unexpected_package, ^disconnect}}} assert_receive {ScriptedMqttServer, :completed} + + # the handle disconnect callback should have been called + assert_receive {{TestHandler, :handle_disconnect}, ^disconnect} + # the callback tells it to stop normally + assert_receive {:EXIT, ^pid, :normal} end + + test "handle_disconnect producing next action", context do + disconnect = %Package.Disconnect{reason: :normal_disconnection} + + callbacks = [ + handle_disconnect: fn %Package.Disconnect{} = disconnect, state -> + %{parent: parent} = state + send(parent, {{TestHandler, :handle_disconnect}, disconnect}) + fun = fn _ -> send(parent, {TestHandler, :from_eval_fun}) end + {:cont, state, [{:eval, fun}]} + end + ] + + {:ok, context} = connect_and_perform_handshake(context, callbacks) + + script = [{:send, disconnect}] + {:ok, _} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + + assert_receive {ScriptedMqttServer, :completed} + # the handle disconnect callback should have been called + assert_receive {{TestHandler, :handle_disconnect}, ^disconnect} + assert_receive {TestHandler, :from_eval_fun} + end + end + + defp connect_and_perform_handshake(%{client_id: client_id} = context, callbacks) do + script = [ + {:receive, %Package.Connect{client_id: client_id}}, + {:send, %Package.Connack{reason: :success, session_present: false}} + ] + + {:ok, {ip, port}} = ScriptedMqttServer.enact(context.scripted_mqtt_server, script) + + opts = [ + client_id: context.client_id, + server: {Tortoise.Transport.Tcp, [host: ip, port: port]}, + handler: {TestHandler, Keyword.merge([parent: self()], callbacks)} + ] + + {:ok, connection_pid} = Connection.start_link(opts) + + assert_receive {{TestHandler, :init}, _} + assert_receive {ScriptedMqttServer, {:received, %Package.Connect{}}} + assert_receive {ScriptedMqttServer, :completed} + + {:ok, Map.put(context, :connection_pid, connection_pid)} end end diff --git a/test/tortoise/events_test.exs b/test/tortoise/events_test.exs deleted file mode 100644 index a48b2edb..00000000 --- a/test/tortoise/events_test.exs +++ /dev/null @@ -1,149 +0,0 @@ -defmodule Tortoise.EventsTest do - use ExUnit.Case, async: true - - setup context do - {:ok, %{client_id: context.test, transport: Tortoise.Transport.Tcp}} - end - - defp via_name(client_id) do - Tortoise.Connection.via_name(client_id) - end - - def run_setup(context, setup) when is_atom(setup) do - context_update = - case apply(__MODULE__, setup, [context]) do - {:ok, update} -> update - [{_, _} | _] = update -> update - %{} = update -> update - end - - Enum.into(context_update, context) - end - - def setup_connection(context) do - {:ok, client_socket, server_socket} = Tortoise.Integration.TestTCPTunnel.new() - name = via_name(context.client_id) - :ok = Tortoise.Registry.put_meta(name, :connecting) - - {:ok, %{client: client_socket, server: server_socket}} - end - - describe "passive connection" do - setup [:setup_connection] - - test "get connection", context do - parent = self() - - child = - spawn_link(fn -> - send(parent, :ready) - {:ok, connection} = Tortoise.Connection.connection(context.client_id) - send(parent, {:received, connection}) - :timer.sleep(:infinity) - end) - - # make sure the child process is ready - assert_receive :ready - - # dispatch the connection - connection = {context.transport, context.client} - :ok = Tortoise.Events.dispatch(context.client_id, :connection, connection) - # have the process registered itself - assert [:connection] = Registry.keys(Tortoise.Events, child) - - # the subscriber should receive the connection and unregister - # itself from the connection event - assert_receive {:received, ^connection} - assert [] = Registry.keys(Tortoise.Events, child) - end - end - - describe "active connection" do - setup [:setup_connection] - - test "get connection", context do - client_id = context.client_id - parent = self() - - child = - spawn_link(fn -> - send(parent, :ready) - {:ok, connection} = Tortoise.Connection.connection(context.client_id, active: true) - send(parent, {:received, connection}) - # later it should receive new sockets - receive do - {{Tortoise, ^client_id}, :connection, connection} -> - send(parent, {:received, connection}) - :timer.sleep(:infinity) - after - 500 -> - send(parent, :timeout) - end - end) - - # make sure the child process is ready - assert_receive :ready - - # dispatch the connection - connection = {context.transport, context.client} - :ok = Tortoise.Events.dispatch(context.client_id, :connection, connection) - - # the subscriber should receive the connection and it should - # still be registered for new connections - assert_receive {:received, ^connection} - assert [:connection] = Registry.keys(Tortoise.Events, child) - - context = run_setup(context, :setup_connection) - new_connection = {context.transport, context.client} - :ok = Tortoise.Events.dispatch(context.client_id, :connection, new_connection) - assert_receive {:received, ^new_connection} - assert [:connection] = Registry.keys(Tortoise.Events, child) - end - end - - describe "ping responses" do - test "receive ping responses", context do - client_id1 = Atom.to_string(context.client_id) - client_id2 = client_id1 <> "2" - client_id3 = client_id1 <> "3" - - # register retrieval of ping requests from 1 and 2 - assert {:ok, owner} = Tortoise.Events.register(client_id1, :ping_response) - assert {:ok, ^owner} = Tortoise.Events.register(client_id2, :ping_response) - - # dispatch ping responses; expect from 1 and 2, but not 3 - Tortoise.Events.dispatch(client_id1, :ping_response, 500) - Tortoise.Events.dispatch(client_id2, :ping_response, 500) - Tortoise.Events.dispatch(client_id3, :ping_response, 500) - assert_receive {{Tortoise, ^client_id1}, :ping_response, 500} - assert_receive {{Tortoise, ^client_id2}, :ping_response, 500} - refute_receive {{Tortoise, ^client_id3}, :ping_response, 500} - - # unregister 2, and register 3 - Tortoise.Events.unregister(client_id2, :ping_response) - assert {:ok, ^owner} = Tortoise.Events.register(client_id3, :ping_response) - - # dispatch ping responses, and expect from 1 and 3, not 2 - Tortoise.Events.dispatch(client_id1, :ping_response, 500) - Tortoise.Events.dispatch(client_id2, :ping_response, 500) - Tortoise.Events.dispatch(client_id3, :ping_response, 500) - assert_receive {{Tortoise, ^client_id1}, :ping_response, 500} - refute_receive {{Tortoise, ^client_id2}, :ping_response, 500} - assert_receive {{Tortoise, ^client_id3}, :ping_response, 500} - end - - test "Subscribing to all clients", context do - client_id1 = Atom.to_string(context.client_id) - client_id2 = client_id1 <> "2" - - # :_ means every client id - Tortoise.Events.register(:_, :ping_response) - - Tortoise.Events.dispatch(client_id1, :ping_response, 123) - Tortoise.Events.dispatch(client_id2, :ping_response, 234) - - assert_receive {{Tortoise, ^client_id2}, :ping_response, 234} - assert_receive {{Tortoise, ^client_id1}, :ping_response, 123} - end - end -end diff --git a/test/tortoise/handler_test.exs b/test/tortoise/handler_test.exs index 29b25eba..080fe260 100644 --- a/test/tortoise/handler_test.exs +++ b/test/tortoise/handler_test.exs @@ -3,51 +3,164 @@ defmodule Tortoise.HandlerTest do doctest Tortoise.Handler alias Tortoise.Handler - alias Tortoise.Connection.Inflight.Track alias Tortoise.Package defmodule TestHandler do + @moduledoc """ + A Tortoise callback handler for testing the input given and the + returned output. + + This callback handler rely on having a keyword list as the state; + when a callback is called it will look for an entry relating to + that callback, and if an anonymous function is found it will be + used as the return value. For instance, if the `handle_pubrel/2` + callback is called, if will look for the `pubrel` in the state + keyword list; if the value is nil an `{:cont, state}` will get + returned, if an anonymous function of arity two is found it will + get called with `apply(fun, [pubrel, state])`. This makes it + possible to pass in a known state and set expectations in our + tests. + """ + @behaviour Handler - def init(opts) do - send(opts[:pid], :init) - {:ok, opts} + # For these tests, if the initial_arg is a two-tuple of a function + # and a term we will execute the function with the term as an + # argument and use the return value as the return; otherwise we + # will just return `{:ok, initial_arg}` + def init({fun, opts}) when is_function(fun, 1), do: apply(fun, [opts]) + def init(opts), do: {:ok, opts} + + def terminate(reason, state) do + case Keyword.get(state, :terminate) do + nil -> + :ok + + fun when is_function(fun, 2) -> + apply(fun, [reason, state]) + + fun when is_function(fun) -> + msg = "Callback function for terminate in #{__MODULE__} should be of arity-two" + raise ArgumentError, message: msg + end end - def connection(status, %{next_actions: next_actions} = state) do - send(state[:pid], {:connection, status}) - {:ok, state, next_actions} + def status_change(status, state) do + case state[:status_change] do + nil -> + {:cont, state} + + fun when is_function(fun, 2) -> + apply(fun, [status, state]) + end end - def connection(status, state) do - send(state[:pid], {:connection, status}) - {:ok, state} + def handle_publish(topic_list, publish, state) do + case Keyword.get(state, :publish) do + nil -> + {:cont, state} + + fun when is_function(fun, 3) -> + apply(fun, [topic_list, publish, state]) + + fun when is_function(fun) -> + msg = "Callback function for Publish in #{__MODULE__} should be of arity-three" + raise ArgumentError, message: msg + end end - def subscription(status, topic, state) do - send(state[:pid], {:subscription, status, topic}) - {:ok, state} + def handle_connack(connack, state) do + make_return(connack, state) end - # with next actions - def handle_message(topic, payload, %{next_actions: next_actions} = state) do - send(state[:pid], {:publish, topic, payload}) - {:ok, state, next_actions} + def handle_suback(subscribe, suback, state) do + make_return({subscribe, suback}, state) end - def handle_message(topic, payload, state) do - send(state[:pid], {:publish, topic, payload}) - {:ok, state} + def handle_unsuback(unsubscribe, unsuback, state) do + make_return({unsubscribe, unsuback}, state) end - def terminate(reason, state) do - send(state[:pid], {:terminate, reason}) - :ok + def handle_puback(puback, state) do + make_return(puback, state) + end + + def handle_pubrec(pubrec, state) do + make_return(pubrec, state) + end + + def handle_pubrel(pubrel, state) do + make_return(pubrel, state) + end + + def handle_pubcomp(pubcomp, state) do + make_return(pubcomp, state) + end + + def handle_disconnect({:server, disconnect}, state) do + make_return(disconnect, state) + end + + # `make return` will search the test handler state for a function + # with an arity of two that relate to the given package, and if + # found it will execute that function with the input package as + # the first argument and the handler state as the second. This + # allow us to specify the return value in the test itself, and + # thereby testing everything the user would return in the + # callbacks. If no callback function is defined we will default to + # returning `{:cont, state}`. + @package_to_type %{ + Package.Connack => :connack, + Package.Puback => :puback, + Package.Pubrec => :pubrec, + Package.Pubrel => :pubrel, + Package.Pubcomp => :pubcomp, + Package.Suback => :suback, + Package.Unsuback => :unsuback, + Package.Disconnect => :disconnect + } + + @allowed_package_types Map.keys(@package_to_type) + + defp make_return(%type{} = package, state) when type in @allowed_package_types do + type = @package_to_type[type] + + case Keyword.get(state, type) do + nil -> + {:cont, state} + + fun when is_function(fun, 2) -> + apply(fun, [package, state]) + + fun when is_function(fun) -> + msg = "Callback function for #{type} in #{__MODULE__} should be of arity-two" + raise ArgumentError, message: msg + end + end + + defp make_return({package, %type{} = ack}, state) when type in @allowed_package_types do + type = @package_to_type[type] + + case Keyword.get(state, type) do + nil -> + {:cont, state} + + fun when is_function(fun, 3) -> + apply(fun, [package, ack, state]) + + fun when is_function(fun) -> + msg = "Callback function for #{type} in #{__MODULE__} should be of arity-three" + raise ArgumentError, message: msg + end + end + + defp make_return(%type{}, _) do + raise ArgumentError, message: "Unknown type for #{__MODULE__}: #{type}" end end setup _context do - handler = %Tortoise.Handler{module: TestHandler, initial_args: [pid: self()]} + handler = %Tortoise.Handler{module: TestHandler, initial_args: nil} {:ok, %{handler: handler}} end @@ -55,139 +168,384 @@ defmodule Tortoise.HandlerTest do %Handler{handler | state: update} end - describe "execute init/1" do - test "return ok-tuple", context do - assert {:ok, %Handler{}} = Handler.execute(context.handler, :init) - assert_receive :init + describe "execute_init/1" do + test "return ok-tuple should set the handler state" do + handler = %Handler{module: TestHandler, state: nil, initial_args: make_ref()} + assert {:ok, %Handler{state: state, initial_args: state}} = Handler.execute_init(handler) + end + + test "returning ignore" do + init_fn = fn nil -> :ignore end + handler = %Handler{module: TestHandler, state: nil, initial_args: {init_fn, nil}} + assert :ignore = Handler.execute_init(handler) + end + + test "returning stop with a reason" do + reason = make_ref() + init_fn = fn nil -> {:stop, reason} end + handler = %Handler{module: TestHandler, state: nil, initial_args: {init_fn, nil}} + assert {:stop, ^reason} = Handler.execute_init(handler) end end - describe "execute connection/2" do - test "return ok-tuple", context do - handler = set_state(context.handler, %{pid: self()}) - assert {:ok, %Handler{}} = Handler.execute(handler, {:connection, :up}) - assert_receive {:connection, :up} + describe "execute status_change/2" do + test "return continues", context do + parent = self() + + status_change_fn = fn status, state -> + send(parent, {:status_change, status}) + {:cont, state} + end - assert {:ok, %Handler{}} = Handler.execute(handler, {:connection, :down}) - assert_receive {:connection, :down} + handler = set_state(context.handler, status_change: status_change_fn) + assert {:ok, %Handler{}, []} = Handler.execute_status_change(handler, :up) + assert_receive {:status_change, :up} + + assert {:ok, %Handler{}, []} = Handler.execute_status_change(handler, :down) + assert_receive {:status_change, :down} end - test "return ok-3-tuple", context do + test "return continue with next actions", context do next_actions = [{:subscribe, "foo/bar", qos: 0}] + parent = self() + + status_change_fn = fn status, state -> + send(parent, {:status_change, status}) + {:cont, state, next_actions} + end - handler = - context.handler - |> set_state(%{pid: self(), next_actions: next_actions}) + handler = set_state(context.handler, status_change: status_change_fn) - assert {:ok, %Handler{}} = Handler.execute(handler, {:connection, :up}) + assert {:ok, %Handler{}, ^next_actions} = Handler.execute_status_change(handler, :up) - assert_receive {:connection, :up} - assert_receive {:next_action, {:subscribe, "foo/bar", qos: 0}} + assert_receive {:status_change, :up} - assert {:ok, %Handler{}} = Handler.execute(handler, {:connection, :down}) + assert {:ok, %Handler{}, ^next_actions} = Handler.execute_status_change(handler, :down) - assert_receive {:connection, :down} - assert_receive {:next_action, {:subscribe, "foo/bar", qos: 0}} + assert_receive {:status_change, :down} end end - describe "execute handle_message/2" do - test "return ok-2", context do - handler = set_state(context.handler, %{pid: self()}) - payload = :crypto.strong_rand_bytes(5) - topic = "foo/bar" - publish = %Package.Publish{topic: topic, payload: payload} + describe "execute handle_connack/2" do + test "return continue", context do + connack = %Package.Connack{ + reason: :success, + session_present: false + } - assert {:ok, %Handler{}} = Handler.execute(handler, {:publish, publish}) - # the topic will be in the form of a list making it possible to - # pattern match on the topic levels - assert_receive {:publish, topic_list, ^payload} - assert is_list(topic_list) - assert topic == Enum.join(topic_list, "/") + connack_fn = fn ^connack, state -> {:cont, state} end + handler = set_state(context.handler, connack: connack_fn) + + assert {:ok, %Handler{} = state, []} = Handler.execute_handle_connack(handler, connack) end - test "return ok-3", context do + test "return continue with next actions", context do + connack = %Package.Connack{ + reason: :success, + session_present: false + } + next_actions = [{:subscribe, "foo/bar", [qos: 0]}] - opts = %{pid: self(), next_actions: next_actions} - handler = set_state(context.handler, opts) + connack_fn = fn ^connack, state -> {:cont, state, next_actions} end + handler = set_state(context.handler, connack: connack_fn) + + assert {:ok, %Handler{} = state, ^next_actions} = + Handler.execute_handle_connack(handler, connack) + end + end + + describe "execute handle_publish/2" do + test "return continue", context do payload = :crypto.strong_rand_bytes(5) topic = "foo/bar" publish = %Package.Publish{topic: topic, payload: payload} + parent = self() - assert {:ok, %Handler{}} = Handler.execute(handler, {:publish, publish}) + publish_fn = fn topic_list, ^publish, state -> + send(parent, {:received_topic_list, topic_list}) + {:cont, state} + end - assert_receive {:next_action, {:subscribe, "foo/bar", qos: 0}} + handler = set_state(context.handler, publish: publish_fn) + assert {:ok, %Handler{}, []} = Handler.execute_handle_publish(handler, publish) # the topic will be in the form of a list making it possible to # pattern match on the topic levels - assert_receive {:publish, topic_list, ^payload} + assert_receive {:received_topic_list, topic_list} assert is_list(topic_list) assert topic == Enum.join(topic_list, "/") end - test "return ok-3 with invalid next action", context do - next_actions = [{:unsubscribe, "foo/bar"}, {:invalid, "bar"}] - opts = %{pid: self(), next_actions: next_actions} - handler = set_state(context.handler, opts) + test "return continue with next actions", context do + topic = "foo/bar" payload = :crypto.strong_rand_bytes(5) + next_actions = [{:subscribe, "foo/bar", [qos: 0]}] + publish = %Package.Publish{topic: topic, payload: payload} + publish_fn = fn _topic_list, ^publish, state -> {:cont, state, next_actions} end + handler = set_state(context.handler, publish: publish_fn) + assert {:ok, %Handler{}, ^next_actions} = Handler.execute_handle_publish(handler, publish) + end + + test "return continue with invalid next action", context do topic = "foo/bar" + payload = :crypto.strong_rand_bytes(5) publish = %Package.Publish{topic: topic, payload: payload} + next_actions = [{:unsubscribe, "foo/bar"}, {:invalid, "bar"}] + publish_fn = fn _topic_list, ^publish, state -> {:cont, state, next_actions} end + handler = set_state(context.handler, publish: publish_fn) assert {:error, {:invalid_next_action, [{:invalid, "bar"}]}} = - Handler.execute(handler, {:publish, publish}) + Handler.execute_handle_publish(handler, publish) + end + end - refute_receive {:next_action, {:invalid, "bar"}} - # we should not receive the otherwise valid next_action - refute_receive {:next_action, {:unsubscribe, "foo/bar"}} + describe "execute handle_suback/3" do + test "return continue", context do + subscribe = %Package.Subscribe{ + identifier: 1, + topics: [{"foo", qos: 0}] + } - # the callback is still run so lets check the received data - assert_receive {:publish, topic_list, ^payload} - assert is_list(topic_list) - assert topic == Enum.join(topic_list, "/") + suback = %Package.Suback{identifier: 1, acks: [ok: 0]} + + suback_fn = fn ^subscribe, ^suback, state -> {:cont, state} end + handler = set_state(context.handler, suback: suback_fn) + + assert {:ok, %Handler{} = state, []} = + Handler.execute_handle_suback(handler, subscribe, suback) + end + + test "return continue with next actions", context do + subscribe = %Package.Subscribe{ + identifier: 1, + topics: [{"foo", qos: 0}] + } + + suback = %Package.Suback{identifier: 1, acks: [ok: 0]} + + next_actions = [{:unsubscribe, "foo/bar"}] + + suback_fn = fn ^subscribe, ^suback, state -> {:cont, state, next_actions} end + handler = set_state(context.handler, suback: suback_fn) + + assert {:ok, %Handler{} = state, [{:unsubscribe, "foo/bar", []}]} = + Handler.execute_handle_suback(handler, subscribe, suback) end end - describe "execute subscribe/2" do - test "return ok", context do - subscribe = %Package.Subscribe{identifier: 1, topics: [{"foo", 0}, {"bar", 1}, {"baz", 0}]} - suback = %Package.Suback{identifier: 1, acks: [ok: 0, ok: 0, error: :access_denied]} - caller = {self(), make_ref()} + describe "execute handle_unsuback/3" do + test "return continue", context do + unsubscribe = %Package.Unsubscribe{identifier: 1, topics: ["foo"]} + unsuback = %Package.Unsuback{identifier: 1, results: [:success]} + unsuback_fn = fn ^unsubscribe, ^unsuback, state -> {:cont, state} end + handler = set_state(context.handler, unsuback: unsuback_fn) - track = Track.create({:negative, caller}, subscribe) - {:ok, track} = Track.resolve(track, {:received, suback}) - {:ok, result} = Track.result(track) + assert {:ok, %Handler{} = state, []} = + Handler.execute_handle_unsuback(handler, unsubscribe, unsuback) + end - handler = set_state(context.handler, pid: self()) - assert {:ok, %Handler{}} = Handler.execute(handler, {:subscribe, result}) + test "return continue with next actions", context do + unsubscribe = %Package.Unsubscribe{identifier: 1, topics: ["foo"]} + unsuback = %Package.Unsuback{identifier: 1, results: [:success]} + next_actions = [{:unsubscribe, "foo/bar"}] + unsuback_fn = fn ^unsubscribe, ^unsuback, state -> {:cont, state, next_actions} end + handler = set_state(context.handler, unsuback: unsuback_fn) - assert_receive {:subscription, :up, "foo"} - assert_receive {:subscription, {:error, :access_denied}, "baz"} - assert_receive {:subscription, {:warn, requested: 1, accepted: 0}, "bar"} + assert {:ok, %Handler{} = state, [{:unsubscribe, "foo/bar", []}]} = + Handler.execute_handle_unsuback(handler, unsubscribe, unsuback) end end - describe "execute unsubscribe/2" do - test "return ok", context do - unsubscribe = %Package.Unsubscribe{identifier: 1, topics: ["foo/bar", "baz/quux"]} - unsuback = %Package.Unsuback{identifier: 1} - caller = {self(), make_ref()} + # callbacks for the QoS=1 message exchange + describe "execute handle_puback/2" do + test "return continue", context do + puback = %Package.Puback{identifier: 1} + puback_fn = fn ^puback, state -> {:cont, state} end + handler = set_state(context.handler, puback: puback_fn) - track = Track.create({:negative, caller}, unsubscribe) - {:ok, track} = Track.resolve(track, {:received, unsuback}) - {:ok, result} = Track.result(track) + assert {:ok, %Handler{} = state, []} = Handler.execute_handle_puback(handler, puback) + end - handler = set_state(context.handler, pid: self()) - assert {:ok, %Handler{}} = Handler.execute(handler, {:unsubscribe, result}) - # we should receive two subscription down messages - assert_receive {:subscription, :down, "foo/bar"} - assert_receive {:subscription, :down, "baz/quux"} + test "return continue with next actions", context do + puback = %Package.Puback{identifier: 1} + next_actions = [{:subscribe, "foo/bar", qos: 0}] + puback_fn = fn ^puback, state -> {:cont, state, next_actions} end + handler = set_state(context.handler, puback: puback_fn) + + assert {:ok, %Handler{} = state, ^next_actions} = + Handler.execute_handle_puback(handler, puback) + end + end + + # callbacks for the QoS=2 message exchange + describe "execute handle_pubrec/2" do + test "return continue", context do + pubrec = %Package.Pubrec{identifier: 1} + pubrec_fn = fn ^pubrec, state -> {:cont, state} end + handler = set_state(context.handler, pubrec: pubrec_fn) + + assert {:ok, %Package.Pubrel{identifier: 1}, %Handler{}, []} = + Handler.execute_handle_pubrec(handler, pubrec) + end + + test "return continue with custom pubrel", context do + pubrec = %Package.Pubrec{identifier: 1} + properties = [{"foo", "bar"}] + + pubrec_fn = fn ^pubrec, state -> + {{:cont, %Package.Pubrel{identifier: 1, properties: properties}}, state} + end + + handler = set_state(context.handler, pubrec: pubrec_fn) + + assert {:ok, %Package.Pubrel{identifier: 1, properties: ^properties}, %Handler{}, []} = + Handler.execute_handle_pubrec(handler, pubrec) + end + + test "raise an error if a custom pubrel with the wrong id is returned", context do + pubrec = %Package.Pubrec{identifier: 1} + + pubrec_fn = fn %Package.Pubrec{identifier: id}, state -> + {{:cont, %Package.Pubrel{identifier: id + 1}}, state} + end + + handler = set_state(context.handler, pubrec: pubrec_fn) + + assert_raise CaseClauseError, fn -> + Handler.execute_handle_pubrec(handler, pubrec) + end + end + + test "returning continue with a list should result in a pubrel with user props", context do + pubrec = %Package.Pubrec{identifier: 1} + properties = [{"foo", "bar"}] + pubrec_fn = fn ^pubrec, state -> {{:cont, properties}, state} end + handler = set_state(context.handler, pubrec: pubrec_fn) + + assert {:ok, %Package.Pubrel{identifier: 1, properties: ^properties}, %Handler{}, []} = + Handler.execute_handle_pubrec(handler, pubrec) + end + end + + describe "execute handle_pubrel/2" do + test "return continue", context do + pubrel = %Package.Pubrel{identifier: 1} + pubrel_fn = fn ^pubrel, state -> {:cont, state} end + handler = set_state(context.handler, pubrel: pubrel_fn) + + assert {:ok, %Package.Pubcomp{identifier: 1}, %Handler{} = state, []} = + Handler.execute_handle_pubrel(handler, pubrel) + end + + test "return continue with custom pubcomp", context do + pubrel = %Package.Pubrel{identifier: 1} + properties = [{"foo", "bar"}] + + pubrel_fn = fn %Package.Pubrel{identifier: 1}, state -> + {{:cont, %Package.Pubcomp{identifier: 1, properties: properties}}, state} + end + + handler = set_state(context.handler, pubrel: pubrel_fn) + + assert {:ok, %Package.Pubcomp{identifier: 1, properties: ^properties}, %Handler{} = state, + []} = Handler.execute_handle_pubrel(handler, pubrel) + end + + test "should not allow custom pubcomp with a different id", context do + pubrel = %Package.Pubrel{identifier: 1} + + pubrel_fn = fn %Package.Pubrel{identifier: id}, state -> + {{:cont, %Package.Pubcomp{identifier: id + 1}}, state} + end + + handler = set_state(context.handler, pubrel: pubrel_fn) + + # todo, consider making an IdentifierMismatchError type + assert_raise CaseClauseError, fn -> + Handler.execute_handle_pubrel(handler, pubrel) + end + end + + test "returning {:cont, [{string(), string()}]} become user defined properties", context do + properties = [{"foo", "bar"}, {"bar", "baz"}] + pubrel = %Package.Pubrel{identifier: 1} + + pubrel_fn = fn ^pubrel, state -> + {{:cont, properties}, state} + end + + handler = set_state(context.handler, pubrel: pubrel_fn) + + assert {:ok, %Package.Pubcomp{identifier: 1, properties: ^properties}, %Handler{} = state, + []} = Handler.execute_handle_pubrel(handler, pubrel) + end + end + + describe "execute handle_pubcomp/2" do + test "return continue", context do + pubcomp = %Package.Pubcomp{identifier: 1} + pubcomp_fn = fn ^pubcomp, state -> {:cont, state} end + handler = set_state(context.handler, pubcomp: pubcomp_fn) + + assert {:ok, %Handler{} = state, []} = + handler + |> Handler.execute_handle_pubcomp(pubcomp) + end + + test "return continue with next actions", context do + pubcomp = %Package.Pubcomp{identifier: 1} + next_actions = [{:subscribe, "foo/bar", qos: 0}] + pubcomp_fn = fn ^pubcomp, state -> {:cont, state, next_actions} end + + handler = set_state(context.handler, pubcomp: pubcomp_fn) + + assert {:ok, %Handler{} = state, ^next_actions} = + Handler.execute_handle_pubcomp(handler, pubcomp) + end + end + + describe "execute handle_disconnect/2" do + test "return continue", context do + disconnect = %Package.Disconnect{} + disconnect_fn = fn ^disconnect, state -> {:cont, state} end + handler = set_state(context.handler, disconnect: disconnect_fn) + + assert {:ok, %Handler{} = state, []} = + Handler.execute_handle_disconnect(handler, {:server, disconnect}) + end + + test "return continue with next actions", context do + disconnect = %Package.Disconnect{} + next_actions = [{:subscribe, "foo/bar", qos: 2}] + disconnect_fn = fn ^disconnect, state -> {:cont, state, next_actions} end + + handler = set_state(context.handler, disconnect: disconnect_fn) + + assert {:ok, %Handler{} = state, ^next_actions} = + Handler.execute_handle_disconnect(handler, {:server, disconnect}) + end + + test "return stop with normal reason", context do + disconnect = %Package.Disconnect{} + disconnect_fn = fn ^disconnect, state -> {:stop, :normal, state} end + handler = set_state(context.handler, disconnect: disconnect_fn) + + assert {:stop, :normal, %Handler{} = state} = + Handler.execute_handle_disconnect(handler, {:server, disconnect}) end end describe "execute terminate/2" do test "return ok", context do - handler = set_state(context.handler, pid: self()) - assert :ok = Handler.execute(handler, {:terminate, :normal}) + parent = self() + + terminate_fn = fn reason, _state -> + send(parent, {:terminate, reason}) + :ok + end + + handler = set_state(context.handler, terminate: terminate_fn) + assert :ok = Handler.execute_terminate(handler, :normal) assert_receive {:terminate, :normal} end end diff --git a/test/tortoise/package/auth_test.exs b/test/tortoise/package/auth_test.exs new file mode 100644 index 00000000..6e2379df --- /dev/null +++ b/test/tortoise/package/auth_test.exs @@ -0,0 +1,19 @@ +defmodule Tortoise.Package.AuthTest do + use ExUnit.Case + use ExUnitProperties + + doctest Tortoise.Package.Auth + + alias Tortoise.Package + + property "encoding and decoding auth messages" do + config = %Package.Auth{reason: nil, properties: nil} + + check all auth <- Package.generate(config) do + assert auth == + auth + |> Package.encode() + |> Package.decode() + end + end +end diff --git a/test/tortoise/package/connack_test.exs b/test/tortoise/package/connack_test.exs index 561ae32f..5ecb1917 100644 --- a/test/tortoise/package/connack_test.exs +++ b/test/tortoise/package/connack_test.exs @@ -1,20 +1,19 @@ defmodule Tortoise.Package.ConnackTest do use ExUnit.Case - use EQC.ExUnit - doctest Tortoise.Package.Connack + use ExUnitProperties - import Tortoise.TestGenerators, only: [gen_connack: 0] + doctest Tortoise.Package.Connack alias Tortoise.Package property "encoding and decoding connack messages" do - forall connack <- gen_connack() do - ensure( - connack == - connack - |> Package.encode() - |> Package.decode() - ) + config = %Package.Connack{reason: nil, session_present: nil, properties: nil} + + check all connack <- Package.generate(config) do + assert connack == + connack + |> Package.encode() + |> Package.decode() end end end diff --git a/test/tortoise/package/connect_test.exs b/test/tortoise/package/connect_test.exs index 47290eca..082e27d6 100644 --- a/test/tortoise/package/connect_test.exs +++ b/test/tortoise/package/connect_test.exs @@ -1,20 +1,27 @@ defmodule Tortoise.Package.ConnectTest do use ExUnit.Case - use EQC.ExUnit + use ExUnitProperties + doctest Tortoise.Package.Connect alias Tortoise.Package - import Tortoise.TestGenerators, only: [gen_connect: 0] - property "encoding and decoding connect messages" do - forall connect <- gen_connect() do - ensure( - connect == - connect - |> Package.encode() - |> Package.decode() - ) + config = %Package.Connect{ + user_name: nil, + password: nil, + clean_start: nil, + keep_alive: nil, + client_id: nil, + will: nil, + properties: nil + } + + check all connect <- Package.generate(config) do + assert connect == + connect + |> Package.encode() + |> Package.decode() end end end diff --git a/test/tortoise/package/disconnect_test.exs b/test/tortoise/package/disconnect_test.exs index 9e6e34b0..fd9d4bc1 100644 --- a/test/tortoise/package/disconnect_test.exs +++ b/test/tortoise/package/disconnect_test.exs @@ -1,15 +1,19 @@ defmodule Tortoise.Package.DisconnectTest do use ExUnit.Case + use ExUnitProperties + doctest Tortoise.Package.Disconnect alias Tortoise.Package - test "encoding and decoding disconnect messages" do - disconnect = %Package.Disconnect{} + property "encoding and decoding disconnect messages" do + config = %Package.Disconnect{reason: nil, properties: nil} - assert ^disconnect = - disconnect - |> Package.encode() - |> Package.decode() + check all disconnect <- Package.generate(config) do + assert disconnect == + disconnect + |> Package.encode() + |> Package.decode() + end end end diff --git a/test/tortoise/package/pingreq_test.exs b/test/tortoise/package/pingreq_test.exs index 82c4fc30..7c54354f 100644 --- a/test/tortoise/package/pingreq_test.exs +++ b/test/tortoise/package/pingreq_test.exs @@ -1,15 +1,26 @@ defmodule Tortoise.Package.PingreqTest do use ExUnit.Case + use ExUnitProperties + doctest Tortoise.Package.Pingreq alias Tortoise.Package - test "encoding and decoding ping requests" do - pingreq = %Package.Pingreq{} + property "encoding and decoding pingreq messages" do + # as pingreqs always look the same it might be overkill to have a + # property based testing for this, but it is added for + # completeness; also in case of future changes to the protocol it + # might be eaiser to expand on this test, as I am hoping for user + # defined properties on ping request and responses (just kidding) + # + # Yeah, this is kind of silly... + config = %Package.Pingreq{} - assert ^pingreq = - pingreq - |> Package.encode() - |> Package.decode() + check all pingreq <- Package.generate(config) do + assert pingreq == + pingreq + |> Package.encode() + |> Package.decode() + end end end diff --git a/test/tortoise/package/pingresp_test.exs b/test/tortoise/package/pingresp_test.exs index 2de95612..d63cabd3 100644 --- a/test/tortoise/package/pingresp_test.exs +++ b/test/tortoise/package/pingresp_test.exs @@ -1,15 +1,21 @@ defmodule Tortoise.Package.PingrespTest do use ExUnit.Case + use ExUnitProperties + doctest Tortoise.Package.Pingresp alias Tortoise.Package - test "encoding and decoding ping responses" do - pingresp = %Package.Pingresp{} + property "encoding and decoding pingresp messages" do + # I know, data will always be the same, having a property for this + # is kind of silly... + config = %Package.Pingresp{} - assert ^pingresp = - pingresp - |> Package.encode() - |> Package.decode() + check all pingresp <- Package.generate(config) do + assert pingresp == + pingresp + |> Package.encode() + |> Package.decode() + end end end diff --git a/test/tortoise/package/properties_test.exs b/test/tortoise/package/properties_test.exs new file mode 100644 index 00000000..294653d2 --- /dev/null +++ b/test/tortoise/package/properties_test.exs @@ -0,0 +1,23 @@ +defmodule Tortoise.Package.PropertiesTest do + use ExUnit.Case + # use EQC.ExUnit + doctest Tortoise.Package.Properties + + # alias Tortoise.Package.Properties + + # import Tortoise.TestGenerators, only: [gen_properties: 0] + + # property "encoding and decoding properties" do + # forall properties <- gen_properties() do + # ensure( + # properties == + # properties + # |> Properties.encode() + # |> IO.iodata_to_binary() + # |> Properties.decode() + # ) + # end + # end + @tag :skip + test "encoding and decoding properties" +end diff --git a/test/tortoise/package/puback_test.exs b/test/tortoise/package/puback_test.exs index 435123ee..7e17a81c 100644 --- a/test/tortoise/package/puback_test.exs +++ b/test/tortoise/package/puback_test.exs @@ -1,4 +1,19 @@ defmodule Tortoise.Package.PubackTest do use ExUnit.Case + use ExUnitProperties + doctest Tortoise.Package.Puback + + alias Tortoise.Package + + property "encoding and decoding puback messages" do + config = %Package.Puback{identifier: nil, reason: nil, properties: nil} + + check all puback <- Package.generate(config) do + assert puback == + puback + |> Package.encode() + |> Package.decode() + end + end end diff --git a/test/tortoise/package/pubcomp_test.exs b/test/tortoise/package/pubcomp_test.exs index a13052f8..2dac0df9 100644 --- a/test/tortoise/package/pubcomp_test.exs +++ b/test/tortoise/package/pubcomp_test.exs @@ -1,4 +1,19 @@ defmodule Tortoise.Package.PubcompTest do use ExUnit.Case + use ExUnitProperties + doctest Tortoise.Package.Pubcomp + + alias Tortoise.Package + + property "encoding and decoding pubcomp messages" do + config = %Package.Pubcomp{identifier: nil, reason: nil, properties: nil} + + check all pubcomp <- Package.generate(config) do + assert pubcomp == + pubcomp + |> Package.encode() + |> Package.decode() + end + end end diff --git a/test/tortoise/package/publish_test.exs b/test/tortoise/package/publish_test.exs index 43229ae8..5c9783c1 100644 --- a/test/tortoise/package/publish_test.exs +++ b/test/tortoise/package/publish_test.exs @@ -1,20 +1,27 @@ defmodule Tortoise.Package.PublishTest do use ExUnit.Case - use EQC.ExUnit - doctest Tortoise.Package.Publish + use ExUnitProperties - import Tortoise.TestGenerators, only: [gen_publish: 0] + doctest Tortoise.Package.Publish alias Tortoise.Package property "encoding and decoding publish messages" do - forall publish <- gen_publish() do - ensure( - publish == - publish - |> Package.encode() - |> Package.decode() - ) + config = %Package.Publish{ + identifier: nil, + topic: nil, + payload: nil, + qos: nil, + dup: nil, + retain: nil, + properties: nil + } + + check all publish <- Package.generate(config) do + assert publish == + publish + |> Package.encode() + |> Package.decode() end end end diff --git a/test/tortoise/package/pubrec_test.exs b/test/tortoise/package/pubrec_test.exs index 023e1833..63744920 100644 --- a/test/tortoise/package/pubrec_test.exs +++ b/test/tortoise/package/pubrec_test.exs @@ -1,4 +1,19 @@ defmodule Tortoise.Package.PubrecTest do use ExUnit.Case + use ExUnitProperties + doctest Tortoise.Package.Pubrec + + alias Tortoise.Package + + property "encoding and decoding pubrec messages" do + config = %Package.Pubrec{identifier: nil, reason: nil, properties: nil} + + check all pubrec <- Package.generate(config) do + assert pubrec == + pubrec + |> Package.encode() + |> Package.decode() + end + end end diff --git a/test/tortoise/package/pubrel_test.exs b/test/tortoise/package/pubrel_test.exs index 418c3c85..37139f77 100644 --- a/test/tortoise/package/pubrel_test.exs +++ b/test/tortoise/package/pubrel_test.exs @@ -1,4 +1,19 @@ defmodule Tortoise.Package.PubrelTest do use ExUnit.Case + use ExUnitProperties + doctest Tortoise.Package.Pubrel + + alias Tortoise.Package + + property "encoding and decoding pubrel messages" do + config = %Package.Pubrel{identifier: nil, reason: nil, properties: nil} + + check all pubrel <- Package.generate(config) do + assert pubrel == + pubrel + |> Package.encode() + |> Package.decode() + end + end end diff --git a/test/tortoise/package/suback_test.exs b/test/tortoise/package/suback_test.exs index 6838d042..facdbbef 100644 --- a/test/tortoise/package/suback_test.exs +++ b/test/tortoise/package/suback_test.exs @@ -1,20 +1,19 @@ defmodule Tortoise.Package.SubackTest do use ExUnit.Case - use EQC.ExUnit - doctest Tortoise.Package.Suback + use ExUnitProperties - import Tortoise.TestGenerators, only: [gen_suback: 0] + doctest Tortoise.Package.Suback alias Tortoise.Package property "encoding and decoding suback messages" do - forall suback <- gen_suback() do - ensure( - suback == - suback - |> Package.encode() - |> Package.decode() - ) + config = %Package.Suback{identifier: nil, acks: nil, properties: nil} + + check all suback <- Package.generate(config) do + assert suback == + suback + |> Package.encode() + |> Package.decode() end end end diff --git a/test/tortoise/package/subscribe_test.exs b/test/tortoise/package/subscribe_test.exs index d818e612..7af34bf3 100644 --- a/test/tortoise/package/subscribe_test.exs +++ b/test/tortoise/package/subscribe_test.exs @@ -1,47 +1,45 @@ defmodule Tortoise.Package.SubscribeTest do use ExUnit.Case - use EQC.ExUnit - doctest Tortoise.Package.Subscribe + use ExUnitProperties - import Tortoise.TestGenerators, only: [gen_subscribe: 0] + doctest Tortoise.Package.Subscribe alias Tortoise.Package - alias Tortoise.Package.Subscribe property "encoding and decoding subscribe messages" do - forall subscribe <- gen_subscribe() do - ensure( - subscribe == - subscribe - |> Package.encode() - |> Package.decode() - ) - end - end - - describe "Collectable" do - test "Pick the largest QoS when topic filters repeat in input" do - topic_filters = [{"a", 2}, {"a", 1}, {"a", 0}] - assert %Subscribe{topics: [{"a", 2}]} = Enum.into(topic_filters, %Subscribe{}) - - topic_filters = [{"a", 0}, {"a", 1}, {"a", 2}] - assert %Subscribe{topics: [{"a", 2}]} = Enum.into(topic_filters, %Subscribe{}) + config = %Package.Subscribe{identifier: nil, topics: nil, properties: nil} - topic_filters = [{"a", 1}, {"a", 0}] - assert %Subscribe{topics: [{"a", 1}]} = Enum.into(topic_filters, %Subscribe{}) - - topic_filters = [{"a", 0}, {"a", 0}] - assert %Subscribe{topics: [{"a", 0}]} = Enum.into(topic_filters, %Subscribe{}) - - # if no qos is given it will default to 0, make sure we still - # pick the biggest QoS given in the list in that case - topic_filters = ["b", {"b", 2}, "b"] - assert %Subscribe{topics: [{"b", 2}]} = Enum.into(topic_filters, %Subscribe{}) - end - - test "If no QoS is given it should default to zero" do - topic_filters = ["a"] - assert %Subscribe{topics: [{"a", 0}]} = Enum.into(topic_filters, %Subscribe{}) + check all subscribe <- Package.generate(config) do + assert subscribe == + subscribe + |> Package.encode() + |> Package.decode() end end + + # describe "Collectable" do + # test "Accept tuples of {binary(), opts()} as input" do + # assert %Subscribe{topics: [{"a", [qos: 1, no_local: true]}]} = + # [{"a", [qos: 1, no_local: true]}] + # |> Enum.into(%Subscribe{}) + # end + + # test "Accept tuples of {binary(), qos()} as input" do + # assert %Subscribe{topics: [{"a", [qos: 0]}]} = + # [{"a", 0}] + # |> Enum.into(%Subscribe{}) + # end + + # test "If no QoS is given it should default to zero" do + # assert %Subscribe{topics: [{"a", [qos: 0]}]} = + # ["a"] + # |> Enum.into(%Subscribe{}) + # end + + # test "If two topics are the same the last write should win" do + # assert %Subscribe{topics: [{"a", [qos: 1]}]} = + # [{"a", qos: 2}, {"a", qos: 0}, {"a", qos: 1}] + # |> Enum.into(%Subscribe{}) + # end + # end end diff --git a/test/tortoise/package/unsuback_test.exs b/test/tortoise/package/unsuback_test.exs index d3f9b694..fb43810b 100644 --- a/test/tortoise/package/unsuback_test.exs +++ b/test/tortoise/package/unsuback_test.exs @@ -1,4 +1,19 @@ defmodule Tortoise.Package.UnsubackTest do use ExUnit.Case + use ExUnitProperties + doctest Tortoise.Package.Unsuback + + alias Tortoise.Package + + property "encoding and decoding unsuback messages" do + config = %Package.Unsuback{identifier: nil, results: nil, properties: nil} + + check all unsuback <- Package.generate(config) do + assert unsuback == + unsuback + |> Package.encode() + |> Package.decode() + end + end end diff --git a/test/tortoise/package/unsubscribe_test.exs b/test/tortoise/package/unsubscribe_test.exs index f3059e22..ca12af9c 100644 --- a/test/tortoise/package/unsubscribe_test.exs +++ b/test/tortoise/package/unsubscribe_test.exs @@ -1,20 +1,19 @@ defmodule Tortoise.Package.UnsubscribeTest do use ExUnit.Case - use EQC.ExUnit - doctest Tortoise.Package.Unsubscribe + use ExUnitProperties - import Tortoise.TestGenerators, only: [gen_unsubscribe: 0] + doctest Tortoise.Package.Unsubscribe alias Tortoise.Package property "encoding and decoding unsubscribe messages" do - forall unsubscribe <- gen_unsubscribe() do - ensure( - unsubscribe == - unsubscribe - |> Package.encode() - |> Package.decode() - ) + config = %Package.Unsubscribe{identifier: nil, topics: nil, properties: nil} + + check all unsubscribe <- Package.generate(config) do + assert unsubscribe == + unsubscribe + |> Package.encode() + |> Package.decode() end end end diff --git a/test/tortoise/package_test.exs b/test/tortoise/package_test.exs index 1f6248cf..7accfad0 100644 --- a/test/tortoise/package_test.exs +++ b/test/tortoise/package_test.exs @@ -1,23 +1,38 @@ defmodule Tortoise.PackageTest do use ExUnit.Case - use EQC.ExUnit + # use EQC.ExUnit doctest Tortoise.Package - alias Tortoise.Package - - import Tortoise.TestGenerators, - only: [gen_unsuback: 0, gen_puback: 0, gen_pubcomp: 0, gen_pubrel: 0, gen_pubrec: 0] - - # Test that we support encoding and decoding of all the - # acknowledgement and complete packages - property "encoding and decoding acknowledgement messages" do - forall ack <- oneof([gen_unsuback(), gen_puback(), gen_pubcomp(), gen_pubrel(), gen_pubrec()]) do - ensure( - ack == - ack - |> Package.encode() - |> Package.decode() - ) - end - end + # alias Tortoise.Package + + # import Tortoise.TestGenerators, + # only: [gen_unsuback: 0, gen_puback: 0, gen_pubcomp: 0, gen_pubrel: 0, gen_pubrec: 0] + + # # Test that we support encoding and decoding of all the + # # acknowledgement and complete packages + # property "encoding and decoding acknowledgement messages" do + # forall ack <- oneof([gen_unsuback(), gen_puback(), gen_pubcomp(), gen_pubrel(), gen_pubrec()]) do + # ensure( + # ack == + # ack + # |> Package.encode() + # |> Package.decode() + # ) + # end + # end + + @tag :skip + test "gen_unsuback/0" + + @tag :skip + test "gen_puback/0" + + @tag :skip + test "gen_pubcomp/0" + + @tag :skip + test "gen_pubrel/0" + + @tag :skip + test "gen_pubrec/0" end diff --git a/test/tortoise/pipe_test.exs b/test/tortoise/pipe_test.exs index e09272f5..5b664745 100644 --- a/test/tortoise/pipe_test.exs +++ b/test/tortoise/pipe_test.exs @@ -3,17 +3,17 @@ defmodule Tortoise.PipeTest do doctest Tortoise.Pipe alias Tortoise.{Pipe, Package} - alias Tortoise.Connection.Inflight setup context do {:ok, %{client_id: context.test}} end - def setup_inflight(context) do - opts = [client_id: context.client_id] - {:ok, inflight_pid} = Inflight.start_link(opts) - {:ok, %{inflight_pid: inflight_pid}} - end + # def setup_inflight(context) do + # opts = [client_id: context.client_id, parent: self()] + # {:ok, inflight_pid} = Inflight.start_link(opts) + # :ok = Inflight.update_connection(inflight_pid, context.connection) + # {:ok, %{inflight_pid: inflight_pid}} + # end def setup_registry(context) do key = Tortoise.Registry.via_name(Tortoise.Connection, context.client_id) @@ -26,7 +26,7 @@ defmodule Tortoise.PipeTest do connection = {Tortoise.Transport.Tcp, client_socket} key = Tortoise.Registry.via_name(Tortoise.Connection, context.client_id) Tortoise.Registry.put_meta(key, connection) - {:ok, %{client: client_socket, server: server_socket}} + {:ok, %{client: client_socket, server: server_socket, connection: connection}} end # update the context during a test run @@ -44,11 +44,13 @@ defmodule Tortoise.PipeTest do describe "new/2" do setup [:setup_registry] + @tag skip: true test "generating a pipe when the connection is up", context do context = run_setup(context, :setup_connection) assert %Pipe{} = Pipe.new(context.client_id) end + @tag skip: true test "generating a pipe while the connection is in connecting state", context do parent = self() client_id = context.client_id @@ -71,6 +73,7 @@ defmodule Tortoise.PipeTest do describe "publish/4" do setup [:setup_registry, :setup_connection] + @tag skip: true test "publish a message", context do pipe = Pipe.new(context.test) topic = "foo/bar" @@ -81,6 +84,7 @@ defmodule Tortoise.PipeTest do assert %Package.Publish{topic: ^topic, payload: ^payload} = Package.decode(package) end + @tag skip: true test "replace pipe during a publish if the socket is closed (active:false)", context do client_id = context.client_id parent = self() @@ -123,19 +127,22 @@ defmodule Tortoise.PipeTest do end describe "await/1" do - setup [:setup_registry, :setup_connection, :setup_inflight] + setup [:setup_registry, :setup_connection] + @tag skip: true test "awaiting an empty pending list should complete instantly", context do pipe = Pipe.new(context.client_id) {:ok, %Pipe{pending: []}} = Pipe.await(pipe) end + @tag skip: true test "error with a timeout if given timeout is reached", context do pipe = Pipe.new(context.client_id) pipe = Pipe.publish(pipe, "foo/bar", nil, qos: 1) {:error, :timeout} = Pipe.await(pipe, 20) end + @tag skip: true test "block until pending packages has been acknowledged", context do client_id = context.client_id parent = self() @@ -156,14 +163,14 @@ defmodule Tortoise.PipeTest do # receive the QoS=1 publish so we can get the id and acknowledge it {:ok, package} = :gen_tcp.recv(context.server, 0, 500) assert %Package.Publish{identifier: id} = Package.decode(package) - Inflight.update(client_id, {:received, %Package.Puback{identifier: id}}) + # Inflight.update(client_id, {:received, %Package.Puback{identifier: id}}) send(child, :continue) # receive and acknowledge the QoS=2 publish {:ok, package} = :gen_tcp.recv(context.server, 0, 500) assert %Package.Publish{identifier: id} = Package.decode(package) - Inflight.update(client_id, {:received, %Package.Pubrec{identifier: id}}) - Inflight.update(client_id, {:received, %Package.Pubcomp{identifier: id}}) + # Inflight.update(client_id, {:received, %Package.Pubrec{identifier: id}}) + # Inflight.update(client_id, {:received, %Package.Pubcomp{identifier: id}}) # both messages should be acknowledged by now assert_receive {:result, result} diff --git a/test/tortoise/registry_test.exs b/test/tortoise/registry_test.exs index fcbe1c7e..ca62e923 100644 --- a/test/tortoise/registry_test.exs +++ b/test/tortoise/registry_test.exs @@ -9,14 +9,12 @@ defmodule Tortoise.RegistryTest do Tortoise.Registry.via_name(mod, name) end - test "meta put, get, delete", context do + test "meta put, get", context do key = Tortoise.Registry.via_name(__MODULE__, context.test) value = :crypto.strong_rand_bytes(2) assert :error == Tortoise.Registry.meta(key) assert :ok = Tortoise.Registry.put_meta(key, value) assert {:ok, ^value} = Tortoise.Registry.meta(key) - assert :ok = Tortoise.Registry.delete_meta(key) - assert :error == Tortoise.Registry.meta(key) end end diff --git a/test/tortoise/session_test.exs b/test/tortoise/session_test.exs new file mode 100644 index 00000000..1b96f561 --- /dev/null +++ b/test/tortoise/session_test.exs @@ -0,0 +1,134 @@ +defmodule Tortoise.SessionTest do + use ExUnit.Case, async: true + doctest Tortoise.Session + + alias Tortoise.{Session, Package} + + test "track an incoming publish qos=1" do + # The connection will receive the publish, store that in the + # session, as the backend might be of a kind that doesn't throw + # away packages. We will dispatch this publish message to the user + # defined connection handler, and use the puback it produces to + # progress the state of the session; then we will complete the + # session. + + session = %Tortoise.Session{client_id: "foo"} + + publish_package = + %Package.Publish{identifier: id} = %Package.Publish{qos: 1, identifier: 123, dup: false} + + # The track command will send back the publish message, this will + # allow the backend to insert values to the user defined + # properties (which doesn't make sense in the incoming scenario, + # but will make sense in the outgoing cases) + assert {{:cont, %Package.Publish{identifier: ^id} = publish_package}, %Session{}} = + Session.track(session, {:incoming, publish_package}) + + puback_package = %Package.Puback{identifier: id} + + assert {{:cont, %Package.Puback{identifier: ^id} = puback_package}, %Session{}} = + Session.progress(session, {:outgoing, puback_package}) + + assert {:ok, _} = Session.release(session, id) + end + + test "track an outgoing publish qos=1" do + session = %Tortoise.Session{client_id: "foo"} + + publish_package = %Package.Publish{qos: 1, identifier: nil, dup: false} + + assert {{:cont, %Package.Publish{identifier: id} = publish_package}, %Session{}} = + Session.track(session, {:outgoing, publish_package}) + + puback_package = %Package.Puback{identifier: id} + + assert {{:cont, %Package.Puback{identifier: ^id} = puback_package}, %Session{}} = + Session.progress(session, {:incoming, puback_package}) + + assert {:ok, _} = Session.release(session, id) + end + + test "track an outgoing publish qos=2" do + session = %Tortoise.Session{client_id: "foo"} + + publish_package = %Package.Publish{qos: 2, identifier: nil, dup: false} + + assert {{:cont, %Package.Publish{identifier: id} = publish_package}, %Session{}} = + Session.track(session, {:outgoing, publish_package}) + + pubrec_package = %Package.Pubrec{identifier: id} + + assert {{:cont, %Package.Pubrec{identifier: ^id} = pubrec_package}, %Session{}} = + Session.progress(session, {:incoming, pubrec_package}) + + pubrel_package = %Package.Pubrel{identifier: id} + + assert {{:cont, %Package.Pubrel{identifier: ^id} = pubrel_package}, %Session{}} = + Session.progress(session, {:outgoing, pubrel_package}) + + pubcomp_package = %Package.Pubcomp{identifier: id} + + assert {{:cont, %Package.Pubcomp{identifier: ^id} = pubcomp_package}, %Session{}} = + Session.progress(session, {:incoming, pubcomp_package}) + + assert {:ok, _} = Session.release(session, id) + end + + test "track an incoming publish qos=2" do + session = %Tortoise.Session{client_id: "foo"} + + publish_package = %Package.Publish{qos: 2, identifier: 125, dup: false} + + assert {{:cont, %Package.Publish{identifier: id} = publish_package}, %Session{}} = + Session.track(session, {:incoming, publish_package}) + + pubrec_package = %Package.Pubrec{identifier: id} + + assert {{:cont, %Package.Pubrec{identifier: ^id} = pubrec_package}, %Session{}} = + Session.progress(session, {:outgoing, pubrec_package}) + + pubrel_package = %Package.Pubrel{identifier: id} + + assert {{:cont, %Package.Pubrel{identifier: ^id} = pubrel_package}, %Session{}} = + Session.progress(session, {:incoming, pubrel_package}) + + pubcomp_package = %Package.Pubcomp{identifier: id} + + assert {{:cont, %Package.Pubcomp{identifier: ^id} = pubcomp_package}, %Session{}} = + Session.progress(session, {:outgoing, pubcomp_package}) + + assert {:ok, _} = Session.release(session, id) + end + + test "track an outgoing subscribe package" do + session = %Tortoise.Session{client_id: "foo"} + + subscribe_package = %Package.Subscribe{} + + assert {{:cont, %Package.Subscribe{identifier: id} = subscribe_package}, %Session{}} = + Session.track(session, {:outgoing, subscribe_package}) + + suback_package = %Package.Suback{identifier: id} + + assert {{:cont, %Package.Suback{identifier: id} = suback_package}, %Session{}} = + Session.progress(session, {:incoming, suback_package}) + + assert {:ok, _} = Session.release(session, id) + end + + test "track an outgoing unsubscribe package" do + session = %Tortoise.Session{client_id: "foo"} + + unsubscribe_package = %Package.Unsubscribe{} + + assert {{:cont, %Package.Unsubscribe{identifier: id} = unsubscribe_package}, %Session{}} = + Session.track(session, {:outgoing, unsubscribe_package}) + + unsuback_package = %Package.Unsuback{identifier: id} + + assert {{:cont, %Package.Unsuback{identifier: id} = suback_package}, %Session{}} = + Session.progress(session, {:incoming, unsuback_package}) + + assert {:ok, _} = Session.release(session, id) + end +end diff --git a/test/tortoise_test.exs b/test/tortoise_test.exs index aaccb7a6..8faf5dbc 100644 --- a/test/tortoise_test.exs +++ b/test/tortoise_test.exs @@ -3,58 +3,151 @@ defmodule TortoiseTest do doctest Tortoise alias Tortoise.Package - alias Tortoise.Connection.Inflight setup context do {:ok, %{client_id: context.test, transport: Tortoise.Transport.Tcp}} end - def setup_connection(context) do + def setup_connection(_context) do {:ok, client_socket, server_socket} = Tortoise.Integration.TestTCPTunnel.new() - name = Tortoise.Connection.via_name(context.client_id) - :ok = Tortoise.Registry.put_meta(name, {context.transport, client_socket}) - {:ok, %{client: client_socket, server: server_socket}} - end - - def setup_inflight(context) do - opts = [client_id: context.client_id] - {:ok, pid} = Inflight.start_link(opts) - {:ok, %{inflight_pid: pid}} + # TODO make this setup work again + # name = Tortoise.Connection.via_name(context.client_id) + # connection = {context.transport, client_socket} + # :ok = Tortoise.Registry.put_meta(name, connection) + connection = nil + {:ok, %{client: client_socket, server: server_socket, connection: connection}} end describe "publish/4" do - setup [:setup_connection, :setup_inflight] + setup [:setup_connection] + @tag skip: true test "publish qos=0", context do assert :ok = Tortoise.publish(context.client_id, "foo/bar") assert {:ok, data} = :gen_tcp.recv(context.server, 0, 500) assert %Package.Publish{topic: "foo/bar", qos: 0, payload: nil} = Package.decode(data) end + @tag skip: true test "publish qos=1", context do assert {:ok, _ref} = Tortoise.publish(context.client_id, "foo/bar", nil, qos: 1) assert {:ok, data} = :gen_tcp.recv(context.server, 0, 500) assert %Package.Publish{topic: "foo/bar", qos: 1, payload: nil} = Package.decode(data) end + @tag skip: true + test "publish qos=1 with user defined callbacks", context do + parent = self() + + transforms = [ + publish: fn %type{properties: properties}, [:init] = state -> + send(parent, {:callback, {type, properties}, state}) + {:ok, properties, [type | state]} + end, + puback: fn %type{properties: properties}, state -> + send(parent, {:callback, {type, properties}, state}) + {:ok, [type | state]} + end + ] + + assert {:ok, publish_ref} = + Tortoise.publish(context.client_id, "foo/bar", nil, + qos: 1, + transforms: {transforms, [:init]} + ) + + assert {:ok, data} = :gen_tcp.recv(context.server, 0, 500) + + assert %Package.Publish{identifier: id, topic: "foo/bar", qos: 1, payload: nil} = + Package.decode(data) + + # check the internal transform state + assert_receive {:callback, {Package.Publish, []}, [:init]} + assert_receive {:callback, {Package.Puback, []}, [Package.Publish, :init]} + end + + @tag skip: true test "publish qos=2", context do assert {:ok, _ref} = Tortoise.publish(context.client_id, "foo/bar", nil, qos: 2) assert {:ok, data} = :gen_tcp.recv(context.server, 0, 500) assert %Package.Publish{topic: "foo/bar", qos: 2, payload: nil} = Package.decode(data) end + + @tag skip: true + test "publish qos=2 with custom callbacks", %{client_id: client_id} = context do + parent = self() + + transforms = [ + publish: fn %type{properties: properties}, [:init] = state -> + send(parent, {:callback, {type, properties}, state}) + {:ok, [{:user_property, {"foo", "bar"}} | properties], [type | state]} + end, + pubrec: fn %type{properties: properties}, state -> + send(parent, {:callback, {type, properties}, state}) + {:ok, [type | state]} + end, + pubrel: fn %type{properties: properties}, state -> + send(parent, {:callback, {type, properties}, state}) + properties = [{:user_property, {"hello", "world"}} | properties] + {:ok, properties, [type | state]} + end, + pubcomp: fn %type{properties: properties}, state -> + send(parent, {:callback, {type, properties}, state}) + {:ok, [type | state]} + end + ] + + assert {:ok, publish_ref} = + Tortoise.publish(context.client_id, "foo/bar", nil, + qos: 2, + transforms: {transforms, [:init]} + ) + + assert {:ok, data} = :gen_tcp.recv(context.server, 0, 500) + + assert %Package.Publish{ + identifier: id, + topic: "foo/bar", + qos: 2, + payload: nil, + properties: [user_property: {"foo", "bar"}] + } = Package.decode(data) + + pubrel = %Package.Pubrel{identifier: id} + + assert {:ok, data} = :gen_tcp.recv(context.server, 0, 500) + + expected_pubrel = %Package.Pubrel{ + pubrel + | properties: [user_property: {"hello", "world"}] + } + + assert expected_pubrel == Package.decode(data) + + assert_receive {{Tortoise, ^client_id}, {Package.Publish, ^publish_ref}, :ok} + # check the internal state of the transform; in the test we add + # the type of the package to the state, which we have defied as + # a list: + assert_receive {:callback, {Package.Publish, []}, [:init]} + assert_receive {:callback, {Package.Pubrec, []}, [Package.Publish | _]} + assert_receive {:callback, {Package.Pubrel, []}, [Package.Pubrec | _]} + expected_state = [Package.Pubrel, Package.Pubrec, Package.Publish, :init] + assert_receive {:callback, {Package.Pubcomp, []}, ^expected_state} + end end describe "publish_sync/4" do - setup [:setup_connection, :setup_inflight] + setup [:setup_connection] + @tag skip: true test "publish qos=0", context do assert :ok = Tortoise.publish_sync(context.client_id, "foo/bar") assert {:ok, data} = :gen_tcp.recv(context.server, 0, 500) assert %Package.Publish{topic: "foo/bar", qos: 0, payload: nil} = Package.decode(data) end + @tag skip: true test "publish qos=1", context do - client_id = context.client_id parent = self() spawn_link(fn -> @@ -67,10 +160,10 @@ defmodule TortoiseTest do assert %Package.Publish{identifier: id, topic: "foo/bar", qos: 1, payload: nil} = Package.decode(data) - :ok = Inflight.update(client_id, {:received, %Package.Puback{identifier: id}}) assert_receive :done end + @tag skip: true test "publish qos=2", context do client_id = context.client_id parent = self() @@ -85,11 +178,12 @@ defmodule TortoiseTest do assert %Package.Publish{identifier: id, topic: "foo/bar", qos: 2, payload: nil} = Package.decode(data) - :ok = Inflight.update(client_id, {:received, %Package.Pubrec{identifier: id}}) + # respond with a pubrel + pubrel = %Package.Pubrel{identifier: id} + assert {:ok, data} = :gen_tcp.recv(context.server, 0, 500) - assert %Package.Pubrel{identifier: ^id} = Package.decode(data) + assert ^pubrel = Package.decode(data) - :ok = Inflight.update(client_id, {:received, %Package.Pubcomp{identifier: id}}) assert_receive :done end end