Restructure as monorepo and add flake packages/apps
- Move Elixir code to server/ subdirectory for monorepo structure - Update flake.nix to provide packages and apps outputs for nix run support - Update nix/package.nix to accept src parameter instead of fetchgit - Add NixOS module export for easy consumption Now supports: nix run, nix build, and nix develop from git repo 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "{}"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright {yyyy} {name of copyright owner}
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,49 @@
|
||||
[](https://github.com/ericentin/gen_state_machine/actions?query=workflow%3A%22Elixir+CI%22)
|
||||
|
||||
# GenStateMachine
|
||||
|
||||
An idiomatic Elixir wrapper for `gen_statem` in OTP 19 (and above).
|
||||
|
||||
**Full documentation is available [here](https://hexdocs.pm/gen_state_machine).**
|
||||
|
||||
**You can find the package on Hex [here](https://hex.pm/packages/gen_state_machine).**
|
||||
|
||||
One important difference between `gen_statem` and this wrapper is that you
|
||||
declare your callback mode as part of `use GenStateMachine` in this wrapper,
|
||||
rather than returning it from `callback_mode/0` on OTP 19.1 and up, or your
|
||||
`init/1` and `code_change/4` on versions of OTP prior to 19.1. In versions of
|
||||
OTP prior to 19.1, you can still, however, switch callback modes in
|
||||
`code_change/4` by returning a callback mode.
|
||||
|
||||
Other than that (and the usual automatically-defined default callbacks as a
|
||||
result of `use`-ing `GenStateMachine`), this wrapper does not make any
|
||||
functional alterations.
|
||||
|
||||
This wrapper also provides a OTP error translator for `Logger`, which is
|
||||
automatically added when the `:gen_state_machine` application is started.
|
||||
Optionally, you may add `:gen_state_machine` to `:included_applications` rather
|
||||
than `:applications` as indicated below if you do not want the translator to be
|
||||
added to `Logger`.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Add `gen_state_machine` to your list of dependencies in `mix.exs`:
|
||||
|
||||
```elixir
|
||||
def deps do
|
||||
[{:gen_state_machine, "~> 2.0"}]
|
||||
end
|
||||
```
|
||||
|
||||
2. Ensure `gen_state_machine` is added to your applications:
|
||||
|
||||
```elixir
|
||||
def application do
|
||||
[applications: [:gen_state_machine]]
|
||||
end
|
||||
```
|
||||
|
||||
## Special Thanks
|
||||
|
||||
I would like to give special thanks to @fishcakez and @michalmuskala, who both
|
||||
provided invaluable feedback on this library!
|
||||
@@ -0,0 +1,15 @@
|
||||
{<<"app">>,<<"gen_state_machine">>}.
|
||||
{<<"build_tools">>,[<<"mix">>]}.
|
||||
{<<"description">>,<<"An Elixir wrapper for gen_statem.">>}.
|
||||
{<<"elixir">>,<<"~> 1.5">>}.
|
||||
{<<"files">>,
|
||||
[<<"lib">>,<<"lib/gen_state_machine">>,
|
||||
<<"lib/gen_state_machine/application.ex">>,
|
||||
<<"lib/gen_state_machine/translator.ex">>,<<"lib/gen_state_machine.ex">>,
|
||||
<<"mix.exs">>,<<"README.md">>,<<"LICENSE">>]}.
|
||||
{<<"licenses">>,[<<"Apache 2.0">>]}.
|
||||
{<<"links">>,
|
||||
[{<<"GitHub">>,<<"https://github.com/ericentin/gen_state_machine">>}]}.
|
||||
{<<"name">>,<<"gen_state_machine">>}.
|
||||
{<<"requirements">>,[]}.
|
||||
{<<"version">>,<<"3.0.0">>}.
|
||||
@@ -0,0 +1,773 @@
|
||||
defmodule GenStateMachine do
|
||||
@moduledoc """
|
||||
A behaviour module for implementing a state machine.
|
||||
|
||||
The advantage of using this module is that it will have a standard set of
|
||||
interface functions and include functionality for tracing and error reporting.
|
||||
It will also fit into a supervision tree.
|
||||
|
||||
## Example
|
||||
|
||||
The `GenStateMachine` behaviour abstracts the state machine. Developers are only
|
||||
required to implement the callbacks and functionality they are interested in.
|
||||
|
||||
Let's start with a code example and then explore the available callbacks.
|
||||
Imagine we want a `GenStateMachine` that works like a switch, allowing us to
|
||||
turn it on and off, as well as see how many times the switch has been turned
|
||||
on:
|
||||
|
||||
defmodule Switch do
|
||||
use GenStateMachine
|
||||
|
||||
# Callbacks
|
||||
|
||||
def handle_event(:cast, :flip, :off, data) do
|
||||
{:next_state, :on, data + 1}
|
||||
end
|
||||
|
||||
def handle_event(:cast, :flip, :on, data) do
|
||||
{:next_state, :off, data}
|
||||
end
|
||||
|
||||
def handle_event({:call, from}, :get_count, state, data) do
|
||||
{:next_state, state, data, [{:reply, from, data}]}
|
||||
end
|
||||
end
|
||||
|
||||
# Start the server
|
||||
{:ok, pid} = GenStateMachine.start_link(Switch, {:off, 0})
|
||||
|
||||
# This is the client
|
||||
GenStateMachine.cast(pid, :flip)
|
||||
#=> :ok
|
||||
|
||||
GenStateMachine.call(pid, :get_count)
|
||||
#=> 1
|
||||
|
||||
We start our `Switch` by calling `start_link/3`, passing the module with the
|
||||
server implementation and its initial argument, a tuple where the first element
|
||||
is the initial state and the second is the initial data. We can primarily
|
||||
interact with the state machine by sending two types of messages. **call**
|
||||
messages expect a reply from the server (and are therefore synchronous) while
|
||||
**cast** messages do not.
|
||||
|
||||
Every time you do a `call/3` or a `cast/2`, the message will be handled by
|
||||
`handle_event/4`.
|
||||
|
||||
We can also use the `:state_functions` callback mode instead of the default,
|
||||
which is `:handle_event_function`:
|
||||
|
||||
defmodule Switch do
|
||||
use GenStateMachine, callback_mode: :state_functions
|
||||
|
||||
def off(:cast, :flip, data) do
|
||||
{:next_state, :on, data + 1}
|
||||
end
|
||||
def off({:call, from}, :get_count, data) do
|
||||
{:keep_state_and_data, [{:reply, from, data}]}
|
||||
end
|
||||
|
||||
def on(:cast, :flip, data) do
|
||||
{:next_state, :off, data}
|
||||
end
|
||||
def on({:call, from}, :get_count, data) do
|
||||
{:keep_state_and_data, [{:reply, from, data}]}
|
||||
end
|
||||
end
|
||||
|
||||
# Start the server
|
||||
{:ok, pid} = GenStateMachine.start_link(Switch, {:off, 0})
|
||||
|
||||
# This is the client
|
||||
GenStateMachine.cast(pid, :flip)
|
||||
#=> :ok
|
||||
|
||||
GenStateMachine.call(pid, :get_count)
|
||||
#=> 1
|
||||
|
||||
Again, we start our `Switch` by calling `start_link/3`, passing the module
|
||||
with the server implementation and its initial argument, and interacting with
|
||||
it via **call** and **cast**.
|
||||
|
||||
However, in `:state_functions` callback mode, every time you do a `call/3` or
|
||||
a `cast/2`, the message will be handled by the `state_name/3` function which
|
||||
is named the same as the current state.
|
||||
|
||||
## Callbacks
|
||||
|
||||
In the default `:handle_event_function` callback mode, there are 4 callbacks
|
||||
required to be implemented. By adding `use GenStateMachine` to your module,
|
||||
Elixir will automatically define all 4 callbacks for you, leaving it up to you
|
||||
to implement the ones you want to customize.
|
||||
|
||||
In the `:state_functions` callback mode, there are 3 callbacks required to be
|
||||
implemented. By adding `use GenStateMachine, callback_mode: :state_functions`
|
||||
to your module, Elixir will automatically define all 3 callbacks for you,
|
||||
leaving it up to you to implement the ones you want to customize, as well as
|
||||
`state_name/3` functions named the same as the states you wish to support.
|
||||
|
||||
It is important to note that the default implementation of the `code_change/4`
|
||||
callback results in an `:undefined` error. This is because `code_change/4` is
|
||||
related to the quite difficult topic of hot upgrades, and if you need it, you
|
||||
should really be implementing it yourself. In normal use this callback will
|
||||
not be invoked.
|
||||
|
||||
## State Enter Callbacks
|
||||
|
||||
If you wish, you can register to receive callbacks when you enter a state by
|
||||
adding `:state_enter` to your `callback_mode`:
|
||||
|
||||
defmodule Switch do
|
||||
use GenStateMachine, callback_mode: [:handle_event_function, :state_enter]
|
||||
|
||||
def handle_event(:enter, _event, state, data) do
|
||||
{:next_state, state, %{data | enters: data.enters + 1}}
|
||||
end
|
||||
|
||||
def handle_event(:cast, :flip, :off, data) do
|
||||
{:next_state, :on, %{data | flips: data.flips + 1}}
|
||||
end
|
||||
|
||||
def handle_event(:cast, :flip, :on, data) do
|
||||
{:next_state, :off, data}
|
||||
end
|
||||
|
||||
def handle_event({:call, from}, :get_count, _state, data) do
|
||||
{:keep_state_and_data, [{:reply, from, data}]}
|
||||
end
|
||||
end
|
||||
|
||||
# Start the server
|
||||
{:ok, pid} = GenStateMachine.start_link(Switch, {:off, %{enters: 0, flips: 0}})
|
||||
|
||||
# This is the client
|
||||
GenStateMachine.cast(pid, :flip)
|
||||
GenStateMachine.cast(pid, :flip)
|
||||
#=> :ok
|
||||
|
||||
GenStateMachine.call(pid, :get_count)
|
||||
#=> %{enters: 3, flips: 1}
|
||||
|
||||
See the Erlang [documentation](https://erlang.org/doc/man/gen_statem.html#type-state_enter)
|
||||
for more details.
|
||||
|
||||
## Name Registration
|
||||
|
||||
Both `start_link/3` and `start/3` support registering the `GenStateMachine`
|
||||
under a given name on start via the `:name` option. Registered names are also
|
||||
automatically cleaned up on termination. The supported values are:
|
||||
|
||||
* an atom - the `GenStateMachine` is registered locally with the given name
|
||||
using `Process.register/2`.
|
||||
|
||||
* `{:global, term}`- the `GenStateMachine` is registered globally with the
|
||||
given term using the functions in the `:global` module.
|
||||
|
||||
* `{:via, module, term}` - the `GenStateMachine` is registered with the given
|
||||
mechanism and name. The `:via` option expects a module that exports
|
||||
`register_name/2`, `unregister_name/1`, `whereis_name/1` and `send/2`.
|
||||
One such example is the `:global` module which uses these functions
|
||||
for keeping the list of names of processes and their associated pid's
|
||||
that are available globally for a network of Erlang nodes.
|
||||
|
||||
For example, we could start and register our Switch server locally as follows:
|
||||
|
||||
# Start the server and register it locally with name MySwitch
|
||||
{:ok, _} = GenStateMachine.start_link(Switch, {:off, 0}, name: MySwitch)
|
||||
|
||||
# Now messages can be sent directly to MySwitch
|
||||
GenStateMachine.call(MySwitch, :get_count) #=> 0
|
||||
|
||||
Once the server is started, the remaining functions in this module (`call/3`,
|
||||
`cast/2`, and friends) will also accept an atom, or any `:global` or `:via`
|
||||
tuples. In general, the following formats are supported:
|
||||
|
||||
* a `pid`
|
||||
* an `atom` if the server is locally registered
|
||||
* `{atom, node}` if the server is locally registered at another node
|
||||
* `{:global, term}` if the server is globally registered
|
||||
* `{:via, module, name}` if the server is registered through an alternative
|
||||
registry
|
||||
|
||||
## Client / Server APIs
|
||||
|
||||
Although in the example above we have used `GenStateMachine.start_link/3` and
|
||||
friends to directly start and communicate with the server, most of the
|
||||
time we don't call the `GenStateMachine` functions directly. Instead, we wrap
|
||||
the calls in new functions representing the public API of the server.
|
||||
|
||||
Here is a better implementation of our Switch module:
|
||||
|
||||
defmodule Switch do
|
||||
use GenStateMachine
|
||||
|
||||
# Client
|
||||
|
||||
def start_link() do
|
||||
GenStateMachine.start_link(Switch, {:off, 0})
|
||||
end
|
||||
|
||||
def flip(pid) do
|
||||
GenStateMachine.cast(pid, :flip)
|
||||
end
|
||||
|
||||
def get_count(pid) do
|
||||
GenStateMachine.call(pid, :get_count)
|
||||
end
|
||||
|
||||
# Server (callbacks)
|
||||
|
||||
def handle_event(:cast, :flip, :off, data) do
|
||||
{:next_state, :on, data + 1}
|
||||
end
|
||||
|
||||
def handle_event(:cast, :flip, :on, data) do
|
||||
{:next_state, :off, data}
|
||||
end
|
||||
|
||||
def handle_event({:call, from}, :get_count, state, data) do
|
||||
{:next_state, state, data, [{:reply, from, data}]}
|
||||
end
|
||||
|
||||
def handle_event(event_type, event_content, state, data) do
|
||||
# Call the default implementation from GenStateMachine
|
||||
super(event_type, event_content, state, data)
|
||||
end
|
||||
end
|
||||
|
||||
In practice, it is common to have both server and client functions in
|
||||
the same module. If the server and/or client implementations are growing
|
||||
complex, you may want to have them in different modules.
|
||||
|
||||
## Receiving custom messages
|
||||
|
||||
The goal of a `GenStateMachine` is to abstract the "receive" loop for
|
||||
developers, automatically handling system messages, support code change,
|
||||
synchronous calls and more. Therefore, you should never call your own
|
||||
"receive" inside the `GenStateMachine` callbacks as doing so will cause the
|
||||
`GenStateMachine` to misbehave. If you want to receive custom messages, they
|
||||
will be delivered to the usual handler for your callback mode with event_type
|
||||
`:info`.
|
||||
|
||||
## Learn more
|
||||
|
||||
If you wish to find out more about gen_statem, the documentation and links
|
||||
in Erlang can provide extra insight.
|
||||
|
||||
* [`:gen_statem` module documentation](http://erlang.org/doc/man/gen_statem.html)
|
||||
* [gen_statem Behaviour – OTP Design Principles](http://erlang.org/doc/design_principles/statem.html)
|
||||
"""
|
||||
|
||||
@typedoc """
|
||||
The term representing the current state.
|
||||
|
||||
In `:handle_event_function` callback mode, any term.
|
||||
|
||||
In `:state_functions` callback mode, an atom.
|
||||
"""
|
||||
@type state :: state_name | term
|
||||
|
||||
@typedoc """
|
||||
The atom representing the current state in `:state_functions` callback mode.
|
||||
"""
|
||||
@type state_name :: atom
|
||||
|
||||
@typedoc """
|
||||
The persistent data (similar to a GenServer's `state`) for the GenStateMachine.
|
||||
"""
|
||||
@type data :: term
|
||||
|
||||
@typedoc """
|
||||
The source of the current event.
|
||||
|
||||
`{:call, from}` will be received as a result of a call.
|
||||
|
||||
`:cast` will be received as a result of a cast.
|
||||
|
||||
`:info` will be received as a result of any regular process messages received.
|
||||
|
||||
`:timeout` or `{:timeout, name}` will be received as a result of a `:timeout` action.
|
||||
|
||||
`:state_timeout` will be received as a result of a `:state_timeout` action.
|
||||
|
||||
`:internal` will be received as a result of a `:next_event` action.
|
||||
|
||||
See the Erlang
|
||||
[documentation](https://erlang.org/doc/man/gen_statem.html#type-event_type)
|
||||
for details.
|
||||
"""
|
||||
@type event_type :: :gen_statem.event_type()
|
||||
|
||||
@typedoc """
|
||||
The callback mode for the GenStateMachine.
|
||||
|
||||
See the Example section above for more info.
|
||||
"""
|
||||
@type callback_mode_result :: :gen_statem.callback_mode_result()
|
||||
|
||||
@typedoc """
|
||||
The message content received as the result of an event.
|
||||
"""
|
||||
@type event_content :: term
|
||||
|
||||
@typedoc """
|
||||
State transition actions.
|
||||
|
||||
They may be invoked by returning them from a state function or init/1.
|
||||
|
||||
If present in a list of actions, they are executed in order, and any that set
|
||||
transition options (postpone, hibernate, and timeout) override any previously
|
||||
provided options of the same type.
|
||||
|
||||
If the state changes, the queue of incoming events is reset to start with the
|
||||
oldest postponed.
|
||||
|
||||
All events added as a result of a `:next_event` action are inserted in the
|
||||
queue to be processed before all other events. An event of type `:internal`
|
||||
should be used when you want to reliably distinguish an event inserted this
|
||||
way from an external event.
|
||||
|
||||
See the Erlang
|
||||
[documentation](https://erlang.org/doc/man/gen_statem.html#type-action)
|
||||
for the possible values.
|
||||
"""
|
||||
@type action :: :gen_statem.action()
|
||||
|
||||
@typedoc """
|
||||
The return type of an event handler function.
|
||||
|
||||
See the Erlang [documentation](https://erlang.org/doc/man/gen_statem.html#type-event_handler_result)
|
||||
for a complete reference.
|
||||
"""
|
||||
@type event_handler_result(state) :: :gen_statem.event_handler_result(state)
|
||||
|
||||
@typedoc """
|
||||
The return type of a state enter function.
|
||||
|
||||
See the Erlang [documentation](https://erlang.org/doc/man/gen_statem.html#type-state_enter_result)
|
||||
for a complete reference.
|
||||
"""
|
||||
@type state_enter_result(state) :: :gen_statem.state_enter_result(state)
|
||||
|
||||
@typedoc """
|
||||
The return type when the server is started.
|
||||
|
||||
See the Erlang [documentation](https://erlang.org/doc/man/gen_statem.html#type-start_ret)
|
||||
for a complete reference.
|
||||
"""
|
||||
@type on_start :: :gen_statem.start_ret()
|
||||
|
||||
@typedoc """
|
||||
The type of allowed server names.
|
||||
|
||||
See the Erlang [documentation](https://erlang.org/doc/man/gen_statem.html#type-server_ref)
|
||||
for a complete reference.
|
||||
"""
|
||||
@type server_ref :: :gen_statem.server_ref()
|
||||
|
||||
@doc """
|
||||
Invoked when the server is started. `start_link/3` (or `start/3`) will
|
||||
block until it returns.
|
||||
|
||||
`args` is the argument term (second argument) passed to `start_link/3`.
|
||||
|
||||
Returning `{:ok, state, data}` will cause `start_link/3` to return
|
||||
`{:ok, pid}` and the process to enter its loop.
|
||||
|
||||
Returning `{:ok, state, data, actions}` is similar to `{:ok, state}`
|
||||
except the provided actions will be executed.
|
||||
|
||||
Returning `:ignore` will cause `start_link/3` to return `:ignore` and the
|
||||
process will exit normally without entering the loop or calling `terminate/2`.
|
||||
If used when part of a supervision tree the parent supervisor will not fail
|
||||
to start nor immediately try to restart the `GenStateMachine`. The remainder
|
||||
of the supervision tree will be (re)started and so the `GenStateMachine`
|
||||
should not be required by other processes. It can be started later with
|
||||
`Supervisor.restart_child/2` as the child specification is saved in the parent
|
||||
supervisor. The main use cases for this are:
|
||||
|
||||
* The `GenStateMachine` is disabled by configuration but might be enabled
|
||||
later.
|
||||
* An error occurred and it will be handled by a different mechanism than the
|
||||
`Supervisor`. Likely this approach involves calling
|
||||
`Supervisor.restart_child/2` after a delay to attempt a restart.
|
||||
|
||||
Returning `{:stop, reason}` will cause `start_link/3` to return
|
||||
`{:error, reason}` and the process to exit with reason `reason` without
|
||||
entering the loop or calling `terminate/2`.
|
||||
|
||||
This function can optionally throw a result to return it.
|
||||
"""
|
||||
@callback init(args :: term) :: :gen_statem.init_result(state)
|
||||
|
||||
@doc """
|
||||
Whenever a `GenStateMachine` in callback mode `:state_functions` receives a
|
||||
call, cast, or normal process message, a state function is called.
|
||||
|
||||
This spec exists to document the callback, but in actual use the name of the
|
||||
function is probably not going to be `state_name`. Instead, there will be at
|
||||
least one state function named after each state you wish to handle. See the
|
||||
Examples section above for more info.
|
||||
|
||||
These functions can optionally throw a result to return it.
|
||||
|
||||
See the Erlang [documentation](https://erlang.org/doc/man/gen_statem.html#Module:StateName-3)
|
||||
for a complete reference.
|
||||
"""
|
||||
@callback state_name(:enter, old_state_name :: state_name, data) ::
|
||||
state_enter_result(state_name)
|
||||
@callback state_name(event_type, event_content, data) :: event_handler_result(state_name)
|
||||
|
||||
@doc """
|
||||
Whenever a `GenStateMachine` in callback mode `:handle_event_function` (the
|
||||
default) receives a call, cast, or normal process messsage, this callback will
|
||||
be invoked.
|
||||
|
||||
This function can optionally throw a result to return it.
|
||||
|
||||
See the Erlang [documentation](https://erlang.org/doc/man/gen_statem.html#Module:handle_event-4)
|
||||
for a complete reference.
|
||||
"""
|
||||
@callback handle_event(:enter, old_state :: state, state, data) :: state_enter_result(state)
|
||||
@callback handle_event(event_type, event_content, state, data) :: event_handler_result(state)
|
||||
|
||||
@doc """
|
||||
Invoked when the server is about to exit. It should do any cleanup required.
|
||||
|
||||
`reason` is exit reason, `state` is the current state, and `data` is the
|
||||
current data of the `GenStateMachine`. The return value is ignored.
|
||||
|
||||
`terminate/2` is called if a callback (except `init/1`) returns a `:stop`
|
||||
tuple, raises, calls `Kernel.exit/1` or returns an invalid value. It may also
|
||||
be called if the `GenStateMachine` traps exits using `Process.flag/2` *and*
|
||||
the parent process sends an exit signal.
|
||||
|
||||
If part of a supervision tree a `GenStateMachine`'s `Supervisor` will send an
|
||||
exit signal when shutting it down. The exit signal is based on the shutdown
|
||||
strategy in the child's specification. If it is `:brutal_kill` the
|
||||
`GenStateMachine` is killed and so `terminate/2` is not called. However if it
|
||||
is a timeout the `Supervisor` will send the exit signal `:shutdown` and the
|
||||
`GenStateMachine` will have the duration of the timeout to call `terminate/2`
|
||||
- if the process is still alive after the timeout it is killed.
|
||||
|
||||
If the `GenStateMachine` receives an exit signal (that is not `:normal`) from
|
||||
any process when it is not trapping exits it will exit abruptly with the same
|
||||
reason and so not call `terminate/2`. Note that a process does *NOT* trap
|
||||
exits by default and an exit signal is sent when a linked process exits or its
|
||||
node is disconnected.
|
||||
|
||||
Therefore it is not guaranteed that `terminate/2` is called when a
|
||||
`GenStateMachine` exits. For such reasons, we usually recommend important
|
||||
clean-up rules to happen in separated processes either by use of monitoring or
|
||||
by links themselves. For example if the `GenStateMachine` controls a `port`
|
||||
(e.g. `:gen_tcp.socket`) or `File.io_device`, they will be closed on receiving
|
||||
a `GenStateMachine`'s exit signal and do not need to be closed in
|
||||
`terminate/2`.
|
||||
|
||||
If `reason` is not `:normal`, `:shutdown` nor `{:shutdown, term}` an error is
|
||||
logged.
|
||||
|
||||
This function can optionally throw a result, which is ignored.
|
||||
"""
|
||||
@callback terminate(reason :: term, state, data) :: any
|
||||
|
||||
@doc """
|
||||
Invoked to change the state of the `GenStateMachine` when a different version
|
||||
of a module is loaded (hot code swapping) and the state and/or data's term
|
||||
structure should be changed.
|
||||
|
||||
`old_vsn` is the previous version of the module (defined by the `@vsn`
|
||||
attribute) when upgrading. When downgrading the previous version is wrapped in
|
||||
a 2-tuple with first element `:down`. `state` is the current state of the
|
||||
`GenStateMachine`, `data` is the current data, and `extra` is any extra data
|
||||
required to change the state.
|
||||
|
||||
Returning `{:ok, new_state, new_data}` changes the state to `new_state`, the
|
||||
data to `new_data`, and the code change is successful.
|
||||
|
||||
Returning `reason` fails the code change with reason `reason` and the state
|
||||
and data remains the same.
|
||||
|
||||
If `code_change/4` raises the code change fails and the loop will continue
|
||||
with its previous state. Therefore this callback does not usually contain side
|
||||
effects.
|
||||
|
||||
This function can optionally throw a result to return it.
|
||||
"""
|
||||
@callback code_change(old_vsn :: term | {:down, vsn :: term}, state, data, extra :: term) ::
|
||||
{:ok, state, data}
|
||||
| (reason :: term)
|
||||
|
||||
@doc """
|
||||
Invoked in some cases to retrieve a formatted version of the `GenStateMachine`
|
||||
status.
|
||||
|
||||
This callback can be useful to control the *appearance* of the status of the
|
||||
`GenStateMachine`. For example, it can be used to return a compact
|
||||
representation of the `GenStateMachines`'s state/data to avoid having large
|
||||
terms printed.
|
||||
|
||||
* one of `:sys.get_status/1` or `:sys.get_status/2` is invoked to get the
|
||||
status of the `GenStateMachine`; in such cases, `reason` is `:normal`
|
||||
|
||||
* the `GenStateMachine` terminates abnormally and logs an error; in such cases,
|
||||
`reason` is `:terminate`
|
||||
|
||||
`pdict_state_and_data` is a three-element list `[pdict, state, data]` where
|
||||
`pdict` is a list of `{key, value}` tuples representing the current process
|
||||
dictionary of the `GenStateMachine`, `state` is the current state of the
|
||||
`GenStateMachine`, and `data` is the current data.
|
||||
|
||||
This function can optionally throw a result to return it.
|
||||
"""
|
||||
@callback format_status(reason :: :normal | :terminate, pdict_state_and_data :: list) :: term
|
||||
|
||||
@optional_callbacks state_name: 3, handle_event: 4, format_status: 2
|
||||
|
||||
@doc false
|
||||
defmacro __using__(opts) do
|
||||
quote location: :keep, bind_quoted: [opts: opts] do
|
||||
@behaviour GenStateMachine
|
||||
|
||||
{callback_mode, opts} = Keyword.pop(opts, :callback_mode, :handle_event_function)
|
||||
|
||||
@doc false
|
||||
def callback_mode do
|
||||
unquote(Macro.escape(callback_mode))
|
||||
end
|
||||
|
||||
@doc false
|
||||
def init({state, data}) do
|
||||
{:ok, state, data}
|
||||
end
|
||||
|
||||
@doc false
|
||||
def terminate(_reason, _state, _data) do
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc false
|
||||
def code_change(_old_vsn, _state, _data, _extra) do
|
||||
:undefined
|
||||
end
|
||||
|
||||
unless Module.get_attribute(__MODULE__, :doc) do
|
||||
@doc """
|
||||
Returns a specification to start this module under a supervisor.
|
||||
|
||||
See `Supervisor`.
|
||||
"""
|
||||
end
|
||||
|
||||
def child_spec(init_arg) do
|
||||
Supervisor.child_spec(
|
||||
%{id: __MODULE__, start: {__MODULE__, :start_link, [init_arg]}},
|
||||
unquote(Macro.escape(opts))
|
||||
)
|
||||
end
|
||||
|
||||
defoverridable init: 1, terminate: 3, code_change: 4, child_spec: 1
|
||||
|
||||
if :handle_event_function in List.wrap(callback_mode) do
|
||||
@doc false
|
||||
def handle_event(_event_type, _event_content, _state, _data) do
|
||||
{:stop, :bad_event}
|
||||
end
|
||||
|
||||
defoverridable handle_event: 4
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts a `GenStateMachine` process linked to the current process.
|
||||
|
||||
This is often used to start the `GenStateMachine` as part of a supervision
|
||||
tree.
|
||||
|
||||
Once the server is started, the `init/1` function of the given `module` is
|
||||
called with `args` as its arguments to initialize the server. To ensure a
|
||||
synchronized start-up procedure, this function does not return until `init/1`
|
||||
has returned.
|
||||
|
||||
Note that a `GenStateMachine` started with `start_link/3` is linked to the
|
||||
parent process and will exit in case of crashes from the parent. The
|
||||
`GenStateMachine` will also exit due to the `:normal` reasons in case it is
|
||||
configured to trap exits in the `init/1` callback.
|
||||
|
||||
## Options
|
||||
|
||||
* `:name` - used for name registration as described in the "Name
|
||||
registration" section of the module documentation
|
||||
|
||||
* `:timeout` - if present, the server is allowed to spend the given amount of
|
||||
milliseconds initializing or it will be terminated and the start function
|
||||
will return `{:error, :timeout}`
|
||||
|
||||
* `:debug` - if present, the corresponding function in the [`:sys`
|
||||
module](http://www.erlang.org/doc/man/sys.html) is invoked
|
||||
|
||||
* `:spawn_opt` - if present, its value is passed as options to the
|
||||
underlying process as in `Process.spawn/4`
|
||||
|
||||
## Return values
|
||||
|
||||
If the server is successfully created and initialized, this function returns
|
||||
`{:ok, pid}`, where `pid` is the pid of the server. If a process with the
|
||||
specified server name already exists, this function returns
|
||||
`{:error, {:already_started, pid}}` with the pid of that process.
|
||||
|
||||
If the `init/1` callback fails with `reason`, this function returns
|
||||
`{:error, reason}`. Otherwise, if it returns `{:stop, reason}`
|
||||
or `:ignore`, the process is terminated and this function returns
|
||||
`{:error, reason}` or `:ignore`, respectively.
|
||||
"""
|
||||
@spec start_link(module, any, GenServer.options()) :: on_start()
|
||||
def start_link(module, args, options \\ []) do
|
||||
{name, options} = Keyword.pop(options, :name)
|
||||
|
||||
if name do
|
||||
name =
|
||||
if is_atom(name) do
|
||||
{:local, name}
|
||||
else
|
||||
name
|
||||
end
|
||||
|
||||
:gen_statem.start_link(name, module, args, options)
|
||||
else
|
||||
:gen_statem.start_link(module, args, options)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts a `GenStateMachine` process without links (outside of a supervision
|
||||
tree).
|
||||
|
||||
See `start_link/3` for more information.
|
||||
"""
|
||||
@spec start(module, any, GenServer.options()) :: on_start()
|
||||
def start(module, args, options \\ []) do
|
||||
{name, options} = Keyword.pop(options, :name)
|
||||
|
||||
if name do
|
||||
name =
|
||||
if is_atom(name) do
|
||||
{:local, name}
|
||||
else
|
||||
name
|
||||
end
|
||||
|
||||
:gen_statem.start(name, module, args, options)
|
||||
else
|
||||
:gen_statem.start(module, args, options)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stops the server with the given `reason`.
|
||||
|
||||
The `terminate/2` callback of the given `server` will be invoked before
|
||||
exiting. This function returns `:ok` if the server terminates with the
|
||||
given reason; if it terminates with another reason, the call exits.
|
||||
|
||||
This function keeps OTP semantics regarding error reporting.
|
||||
If the reason is any other than `:normal`, `:shutdown` or
|
||||
`{:shutdown, _}`, an error report is logged.
|
||||
"""
|
||||
@spec stop(server_ref(), reason :: term, timeout) :: :ok
|
||||
def stop(server, reason \\ :normal, timeout \\ :infinity) do
|
||||
:gen_statem.stop(server, reason, timeout)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Makes a synchronous call to the `server` and waits for its reply.
|
||||
|
||||
The client sends the given `request` to the server and waits until a reply
|
||||
arrives or a timeout occurs. The appropriate state function will be called on
|
||||
the server to handle the request.
|
||||
|
||||
`server` can be any of the values described in the "Name registration"
|
||||
section of the documentation for this module.
|
||||
|
||||
## Timeouts
|
||||
|
||||
`timeout` is an integer greater than zero which specifies how many
|
||||
milliseconds to wait for a reply, or the atom `:infinity` to wait
|
||||
indefinitely. The default value is `:infinity`. If no reply is received within
|
||||
the specified time, the function call fails and the caller exits.
|
||||
|
||||
If the caller catches an exit, to avoid getting a late reply in the caller's
|
||||
inbox, this function spawns a proxy process that does the call. A late reply
|
||||
gets delivered to the dead proxy process, and hence gets discarded. This is
|
||||
less efficient than using `:infinity` as a timeout.
|
||||
"""
|
||||
@spec call(server_ref(), term, timeout) :: term
|
||||
def call(server, request, timeout \\ :infinity) do
|
||||
:gen_statem.call(server, request, timeout)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends an asynchronous request to the `server`.
|
||||
|
||||
This function always returns `:ok` regardless of whether
|
||||
the destination `server` (or node) exists. Therefore it
|
||||
is unknown whether the destination `server` successfully
|
||||
handled the message.
|
||||
|
||||
The appropriate state function will be called on the server to handle
|
||||
the request.
|
||||
"""
|
||||
@spec cast(server_ref(), term) :: :ok
|
||||
def cast(server, request) do
|
||||
:gen_statem.cast(server, request)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends replies to clients.
|
||||
|
||||
Can be used to explicitly send replies to multiple clients.
|
||||
|
||||
This function always returns `:ok`.
|
||||
|
||||
See `reply/2` for more information.
|
||||
"""
|
||||
@spec reply([:gen_statem.reply_action()] | :gen_statem.reply_action()) :: :ok
|
||||
def reply(replies) do
|
||||
:gen_statem.reply(replies)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Replies to a client.
|
||||
|
||||
This function can be used to explicitly send a reply to a client that called
|
||||
`call/3` when the reply cannot be specified in the return value of a state
|
||||
function.
|
||||
|
||||
`client` must be the `from` element of the event type accepted by state
|
||||
functions. `reply` is an arbitrary term which will be given
|
||||
back to the client as the return value of the call.
|
||||
|
||||
Note that `reply/2` can be called from any process, not just the one
|
||||
that originally received the call (as long as that process communicated the
|
||||
`from` argument somehow).
|
||||
|
||||
This function always returns `:ok`.
|
||||
|
||||
## Examples
|
||||
|
||||
def handle_event({:call, from}, :reply_in_one_second, state, data) do
|
||||
Process.send_after(self(), {:reply, from}, 1_000)
|
||||
:keep_state_and_data
|
||||
end
|
||||
|
||||
def handle_event(:info, {:reply, from}, state, data) do
|
||||
GenStateMachine.reply(from, :one_second_has_passed)
|
||||
end
|
||||
|
||||
"""
|
||||
@spec reply(:gen_statem.from(), term) :: :ok
|
||||
def reply(client, reply) do
|
||||
:gen_statem.reply(client, reply)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
defmodule GenStateMachine.Application do
|
||||
@moduledoc false
|
||||
|
||||
use Application
|
||||
|
||||
@doc false
|
||||
def start(_type, _args) do
|
||||
Logger.add_translator({GenStateMachine.Translator, :translate})
|
||||
Supervisor.start_link([], strategy: :one_for_one)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,131 @@
|
||||
defmodule GenStateMachine.Translator do
|
||||
@moduledoc false
|
||||
|
||||
@doc false
|
||||
# OTP21 and after
|
||||
def translate(min_level, :error, :report, {:logger, %{label: label} = report}) do
|
||||
case label do
|
||||
{:gen_statem, :terminate} ->
|
||||
do_translate(min_level, report)
|
||||
|
||||
_ ->
|
||||
:none
|
||||
end
|
||||
end
|
||||
|
||||
# OTP20 and before
|
||||
def translate(min_level, :error, :format, message) do
|
||||
opts = Application.get_env(:logger, :translator_inspect_opts)
|
||||
|
||||
case message do
|
||||
{'** State machine ~tp terminating~n' ++ _ = format, args} ->
|
||||
do_translate(min_level, format, args, opts)
|
||||
|
||||
{'** State machine ~p terminating~n' ++ _ = format, args} ->
|
||||
do_translate(min_level, format, args, opts)
|
||||
|
||||
_ ->
|
||||
:none
|
||||
end
|
||||
end
|
||||
|
||||
def translate(_min_level, _level, _kind, _message) do
|
||||
:none
|
||||
end
|
||||
|
||||
# OTP21 and after
|
||||
defp do_translate(min_level, report) do
|
||||
inspect_opts = Application.get_env(:logger, :translator_inspect_opts)
|
||||
|
||||
%{name: name, state: state} = report
|
||||
|
||||
msg = ["GenStateMachine #{inspect(name)} terminating", statem_exception(report)]
|
||||
|
||||
if min_level == :debug do
|
||||
msg = [msg, "\nState: ", inspect(state, inspect_opts)]
|
||||
{:ok, statem_debug(report, inspect_opts, msg)}
|
||||
else
|
||||
{:ok, msg}
|
||||
end
|
||||
end
|
||||
|
||||
# OTP20 and before
|
||||
defp do_translate(min_level, format, args, opts) do
|
||||
keys =
|
||||
format
|
||||
|> to_string()
|
||||
|> String.split(~r/~tp|~p/, trim: true)
|
||||
|> Enum.flat_map(&map_key/1)
|
||||
|
||||
args =
|
||||
[keys, args]
|
||||
|> List.zip()
|
||||
|> Map.new()
|
||||
|
||||
msg = [
|
||||
"GenStateMachine #{inspect(args.name)} terminating"
|
||||
| statem_exception(args)
|
||||
]
|
||||
|
||||
if min_level == :debug do
|
||||
{:ok, statem_debug(args, opts, msg)}
|
||||
else
|
||||
{:ok, msg}
|
||||
end
|
||||
end
|
||||
|
||||
# OTP21 and later
|
||||
defp statem_exception(%{reason: {class, reason, stack}}) do
|
||||
do_statem_exception(class, reason, stack)
|
||||
end
|
||||
|
||||
# OTP20 and before
|
||||
defp statem_exception(%{class: class, reason: reason, stack: stack}) do
|
||||
do_statem_exception(class, reason, stack)
|
||||
end
|
||||
|
||||
defp do_statem_exception(class, reason, stack) do
|
||||
formatted = Exception.format(class, reason, stack)
|
||||
[?\n | :erlang.binary_part(formatted, 0, byte_size(formatted) - 1)]
|
||||
end
|
||||
|
||||
defp map_key(arg) do
|
||||
String.split(arg, ~r/\*\* |~n/, trim: true)
|
||||
|> Enum.filter(&(String.contains?(&1, "=") || String.contains?(&1, "State machine")))
|
||||
|> case do
|
||||
[] -> []
|
||||
["State machine" <> _] -> [:name]
|
||||
["Last event" <> _] -> [:last_event]
|
||||
["When server state" <> _] -> [:state]
|
||||
["Reason for termination" <> _] -> [:class, :reason]
|
||||
["Callback mode" <> _] -> [:callback_mode]
|
||||
["Stacktrace" <> _] -> [:stack]
|
||||
["Queued" <> _] -> [:queued]
|
||||
["Postponed" <> _] -> [:postponed]
|
||||
end
|
||||
end
|
||||
|
||||
defp statem_debug(args, opts, msg) do
|
||||
[msg, Enum.map(Enum.sort(args), &translate_arg(&1, opts))]
|
||||
end
|
||||
|
||||
defp translate_arg({:last_event, last_event}, opts),
|
||||
do: ["\nLast event: " | inspect(last_event, opts)]
|
||||
|
||||
defp translate_arg({:state, state}, opts),
|
||||
do: ["\nState: " | inspect(state, opts)]
|
||||
|
||||
defp translate_arg({:callback_mode, callback_mode}, opts),
|
||||
do: ["\nCallback mode: " | inspect(callback_mode, opts)]
|
||||
|
||||
defp translate_arg({:queued, queued}, opts),
|
||||
do: ["\nQueued events: " | inspect(queued, opts)]
|
||||
|
||||
defp translate_arg({:queue, [last | queued]}, opts),
|
||||
do: ["\nLast event: ", inspect(last, opts), "\nQueued events: " | inspect(queued, opts)]
|
||||
|
||||
defp translate_arg({:postponed, postponed}, opts),
|
||||
do: ["\nPostponed events: " | inspect(postponed, opts)]
|
||||
|
||||
defp translate_arg(_arg, _opts), do: []
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
otp_release =
|
||||
:erlang.system_info(:otp_release)
|
||||
|> to_string()
|
||||
|> String.split(".")
|
||||
|> List.first()
|
||||
|> String.to_integer()
|
||||
|
||||
if otp_release < 20 do
|
||||
IO.warn("gen_state_machine requires Erlang/OTP 20 or greater", [])
|
||||
end
|
||||
|
||||
defmodule GenStateMachine.Mixfile do
|
||||
use Mix.Project
|
||||
|
||||
@version "3.0.0"
|
||||
|
||||
def project do
|
||||
[
|
||||
app: :gen_state_machine,
|
||||
version: @version,
|
||||
elixir: "~> 1.5",
|
||||
build_embedded: Mix.env() == :prod,
|
||||
start_permanent: Mix.env() == :prod,
|
||||
deps: deps(),
|
||||
description: description(),
|
||||
package: package(),
|
||||
source_url: "https://github.com/ericentin/gen_state_machine",
|
||||
docs: [
|
||||
main: "GenStateMachine",
|
||||
extras: ["README.md"],
|
||||
source_ref: "v#{@version}"
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
def application do
|
||||
[
|
||||
extra_applications: [:logger],
|
||||
mod: {GenStateMachine.Application, []}
|
||||
]
|
||||
end
|
||||
|
||||
defp deps do
|
||||
[
|
||||
{:ex_doc, "~> 0.23", only: :dev, runtime: false},
|
||||
{:dialyxir, "~> 1.0", only: :dev, runtime: false}
|
||||
]
|
||||
end
|
||||
|
||||
defp description do
|
||||
"An Elixir wrapper for gen_statem."
|
||||
end
|
||||
|
||||
defp package do
|
||||
[
|
||||
files: ["lib", "mix.exs", "README.md", "LICENSE"],
|
||||
maintainers: ["Eric Entin"],
|
||||
licenses: ["Apache 2.0"],
|
||||
links: %{
|
||||
"GitHub" => "https://github.com/ericentin/gen_state_machine"
|
||||
}
|
||||
]
|
||||
end
|
||||
end
|
||||
Binary file not shown.
@@ -0,0 +1,132 @@
|
||||
# Changelog
|
||||
|
||||
## 1.4.4 (26.07.2024)
|
||||
|
||||
* Fix warnings on Elixir 1.17 by conditionally compiling Decimal support
|
||||
|
||||
## 1.4.3 (29.06.2024)
|
||||
|
||||
* Fix derive with _ struct key
|
||||
|
||||
## 1.4.2 (29.06.2024)
|
||||
|
||||
* Fix compiler warnings for Elixir 1.17
|
||||
|
||||
## 1.4.1 (06.07.2023)
|
||||
|
||||
* Add limit to decoded integer sizes of 1024 digits. This can be changed
|
||||
with the `decoding_integer_digit_limit` app env config.
|
||||
|
||||
## 1.4.0 (12.09.2022)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* Use the `:erlang.float_to_binary(_, [:short])` function, instead of `io_lib_format.fwrite_g/1`
|
||||
where available (OTP 24.1+). This provides equivalent output with much less memory used
|
||||
and significantly improved performance.
|
||||
|
||||
## 1.3.0 (21.12.2021)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* Add the `Jason.OrderedObject` struct
|
||||
* Support decoding objects preserving all the keys with `objects: :ordered_objects` option
|
||||
* Support decoding floats to `Decimal` with `floats: :decimals` option
|
||||
* Add `~j` and `~J` sigils in module `Jason.Sigil` to support writing JSON literals in code
|
||||
|
||||
### Fixes
|
||||
* Fix error reporting when decoding strings (it was possible to mis-attribute the offending byte)
|
||||
* Verify fields given to `@derive`
|
||||
|
||||
## 1.2.2 (08.09.2020)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* Support Decimal 2.0
|
||||
|
||||
## 1.2.1 (04.05.2020)
|
||||
|
||||
### Security
|
||||
|
||||
* Fix `html_safe` escaping in `Jason.encode`
|
||||
|
||||
The `<!--` sequence of characters would not be escaped in `Jason.encode`
|
||||
with`html_escape` mode, which could lead to DoS attacks when used for
|
||||
embedding of arbitrary, user controlled strings into HTML through JSON
|
||||
(e.g. inside of `<script>` tags).
|
||||
|
||||
If you were not using the `html_safe` option, you are not affected.
|
||||
|
||||
Affected versions: < 1.2.1
|
||||
Patched versions: >= 1.2.1
|
||||
|
||||
## 1.2.0 (17.03.2020)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* Add `Jason.Encode.keyword/2`
|
||||
([cb1f26a](https://github.com/michalmuskala/jason/commit/cb1f26a)).
|
||||
|
||||
### Bug fixes
|
||||
|
||||
* Fix `Jason.Helpers.json_map/1` value expansion
|
||||
([70b046a](https://github.com/michalmuskala/jason/commit/70b046a)).
|
||||
|
||||
## 1.1.2 (19.10.2018)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
* correctly handle the `pretty: false` option
|
||||
([ba318c8](https://github.com/michalmuskala/jason/commit/ba318c8)).
|
||||
|
||||
## 1.1.1 (10.07.2018)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
* correctly handle escape sequences in strings when pretty printing
|
||||
([794bbe4](https://github.com/michalmuskala/jason/commit/794bbe4)).
|
||||
|
||||
## 1.1.0 (02.07.2018)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* pretty-printing support through `Jason.Formatter` and `pretty: true` option
|
||||
in `Jason.encode/2` ([d758e36](https://github.com/michalmuskala/jason/commit/d758e36)).
|
||||
|
||||
### Bug fixes
|
||||
|
||||
* silence variable warnings for fields with underscores used during deriving
|
||||
([88dd85c](https://github.com/michalmuskala/jason/commit/88dd85c)).
|
||||
* **potential incompatibility** don't raise `Protocol.UndefinedError` in non-bang functions
|
||||
([ad0f57b](https://github.com/michalmuskala/jason/commit/ad0f57b)).
|
||||
|
||||
## 1.0.1 (02.07.2018)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
* fix `Jason.Encode.escape` type ([a57b430](https://github.com/michalmuskala/jason/commit/a57b430))
|
||||
* multiple documentation improvements
|
||||
|
||||
## 1.0.0 (26.01.2018)
|
||||
|
||||
No changes
|
||||
|
||||
## 1.0.0-rc.3 (26.01.2018)
|
||||
|
||||
### Changes
|
||||
|
||||
* update `escape` option of `Jason.encode/2` to take values:
|
||||
`:json | :unicode_safe | :html_safe | :javascript_safe` for consistency. Old values of
|
||||
`:unicode` and `:javascript` are still supported for compatibility with Poison.
|
||||
([f42dcbd](https://github.com/michalmuskala/jason/commit/f42dcbd))
|
||||
|
||||
## 1.0.0-rc.2 (07.01.2018)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
* add type for `strings` option ([b459ee4](https://github.com/michalmuskala/jason/commit/b459ee4))
|
||||
* support iodata in `decode!` ([a1f3456](https://github.com/michalmuskala/jason/commit/a1f3456))
|
||||
|
||||
## 1.0.0-rc.1 (22.12.2017)
|
||||
|
||||
* Initial release
|
||||
@@ -0,0 +1,13 @@
|
||||
Copyright (c) 2017-present Michał Muskała
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,157 @@
|
||||
# Jason
|
||||
|
||||
A blazing fast JSON parser and generator in pure Elixir.
|
||||
|
||||
The parser and generator are at least twice as fast as other Elixir/Erlang libraries
|
||||
(most notably `Poison`).
|
||||
The performance is comparable to `jiffy`, which is implemented in C as a NIF.
|
||||
Jason is usually only twice as slow.
|
||||
|
||||
Both parser and generator fully conform to
|
||||
[RFC 8259](https://tools.ietf.org/html/rfc8259) and
|
||||
[ECMA 404](http://www.ecma-international.org/publications/standards/Ecma-404.htm)
|
||||
standards. The parser is tested using [JSONTestSuite](https://github.com/nst/JSONTestSuite).
|
||||
|
||||
## Installation
|
||||
|
||||
The package can be installed by adding `jason` to your list of dependencies
|
||||
in `mix.exs`:
|
||||
|
||||
```elixir
|
||||
def deps do
|
||||
[{:jason, "~> 1.4"}]
|
||||
end
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
``` elixir
|
||||
iex(1)> Jason.encode!(%{"age" => 44, "name" => "Steve Irwin", "nationality" => "Australian"})
|
||||
"{\"age\":44,\"name\":\"Steve Irwin\",\"nationality\":\"Australian\"}"
|
||||
|
||||
iex(2)> Jason.decode!(~s({"age":44,"name":"Steve Irwin","nationality":"Australian"}))
|
||||
%{"age" => 44, "name" => "Steve Irwin", "nationality" => "Australian"}
|
||||
```
|
||||
|
||||
Full documentation can be found at [https://hexdocs.pm/jason](https://hexdocs.pm/jason).
|
||||
|
||||
## Use with other libraries
|
||||
|
||||
### Postgrex
|
||||
|
||||
Versions starting at 0.14.0 use `Jason` by default. For earlier versions, please refer to
|
||||
[previous versions of this document](https://github.com/michalmuskala/jason/tree/v1.1.2#postgrex).
|
||||
|
||||
### Ecto
|
||||
|
||||
Versions starting at 3.0.0 use `Jason` by default. For earlier versions, please refer to
|
||||
[previous versions of this document](https://github.com/michalmuskala/jason/tree/v1.1.2#ecto).
|
||||
|
||||
### Plug (and Phoenix)
|
||||
|
||||
Phoenix starting at 1.4.0 uses `Jason` by default. For earlier versions, please refer to
|
||||
[previous versions of this document](https://github.com/michalmuskala/jason/tree/v1.1.2#plug-and-phoenix).
|
||||
|
||||
### Absinthe
|
||||
|
||||
You need to pass the `:json_codec` option to `Absinthe.Plug`
|
||||
|
||||
```elixir
|
||||
# When called directly:
|
||||
plug Absinthe.Plug,
|
||||
schema: MyApp.Schema,
|
||||
json_codec: Jason
|
||||
|
||||
# When used in phoenix router:
|
||||
forward "/api",
|
||||
to: Absinthe.Plug,
|
||||
init_opts: [schema: MyApp.Schema, json_codec: Jason]
|
||||
```
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Detailed benchmarks (including memory measurements):
|
||||
https://gist.github.com/michalmuskala/4d64a5a7696ca84ac7c169a0206640d5
|
||||
|
||||
HTML reports for the benchmark (only performance measurements):
|
||||
http://michal.muskala.eu/jason/decode.html and http://michal.muskala.eu/jason/encode.html
|
||||
|
||||
### Running
|
||||
|
||||
Benchmarks against most popular Elixir & Erlang json libraries can be executed after
|
||||
going into the `bench/` folder and then executing `mix bench.encode` and `mix bench.decode`.
|
||||
A HTML report of the benchmarks (after their execution) can be found in
|
||||
`bench/output/encode.html` and `bench/output/decode.html` respectively.
|
||||
|
||||
## Differences to Poison
|
||||
|
||||
Jason has a couple feature differences compared to Poison.
|
||||
|
||||
* Jason follows the JSON spec more strictly, for example it does not allow
|
||||
unescaped newline characters in JSON strings - e.g. `"\"\n\""` will
|
||||
produce a decoding error.
|
||||
* no support for decoding into data structures (the `as:` option).
|
||||
* no built-in encoders for `MapSet`, `Range` and `Stream`.
|
||||
* no support for encoding arbitrary structs - explicit implementation
|
||||
of the `Jason.Encoder` protocol is always required.
|
||||
* different pretty-printing customisation options (default `pretty: true` works the same)
|
||||
|
||||
### Encoders
|
||||
|
||||
If you require encoders for any of the unsupported collection types, I suggest
|
||||
adding the needed implementations directly to your project:
|
||||
|
||||
```elixir
|
||||
defimpl Jason.Encoder, for: [MapSet, Range, Stream] do
|
||||
def encode(struct, opts) do
|
||||
Jason.Encode.list(Enum.to_list(struct), opts)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
If you need to encode some struct that does not implement the protocol,
|
||||
if you own the struct, you can derive the implementation specifying
|
||||
which fields should be encoded to JSON:
|
||||
|
||||
```elixir
|
||||
@derive {Jason.Encoder, only: [....]}
|
||||
defstruct # ...
|
||||
```
|
||||
|
||||
It is also possible to encode all fields, although this should be
|
||||
used carefully to avoid accidentally leaking private information
|
||||
when new fields are added:
|
||||
|
||||
```elixir
|
||||
@derive Jason.Encoder
|
||||
defstruct # ...
|
||||
```
|
||||
|
||||
Finally, if you don't own the struct you want to encode to JSON,
|
||||
you may use `Protocol.derive/3` placed outside of any module:
|
||||
|
||||
```elixir
|
||||
Protocol.derive(Jason.Encoder, NameOfTheStruct, only: [...])
|
||||
Protocol.derive(Jason.Encoder, NameOfTheStruct)
|
||||
```
|
||||
|
||||
## Injecting an already encoded JSON inside a to-be-encoded structure
|
||||
|
||||
If parts of the to-be-encoded structure are already JSON-encoded, you can
|
||||
use `Jason.Fragment` to mark the parts as already encoded, and avoid a
|
||||
decoding/encoding roundtrip.
|
||||
|
||||
```elixir
|
||||
already_encoded_json = Jason.encode!(%{hello: "world"})
|
||||
Jason.encode!(%{foo: Jason.Fragment.new(already_encoded_json)})
|
||||
```
|
||||
|
||||
This feature is especially useful if you need to cache a part of the JSON,
|
||||
or if it is already provided by another system (e.g. `jsonb_agg` with Postgres).
|
||||
|
||||
## License
|
||||
|
||||
Jason is released under the Apache License 2.0 - see the [LICENSE](LICENSE) file.
|
||||
|
||||
Some elements of tests and benchmarks have their origins in the
|
||||
[Poison library](https://github.com/devinus/poison) and were initially licensed under [CC0-1.0](https://creativecommons.org/publicdomain/zero/1.0/).
|
||||
@@ -0,0 +1,21 @@
|
||||
{<<"links">>,[{<<"GitHub">>,<<"https://github.com/michalmuskala/jason">>}]}.
|
||||
{<<"name">>,<<"jason">>}.
|
||||
{<<"version">>,<<"1.4.4">>}.
|
||||
{<<"description">>,
|
||||
<<"A blazing fast JSON parser and generator in pure Elixir.">>}.
|
||||
{<<"elixir">>,<<"~> 1.4">>}.
|
||||
{<<"app">>,<<"jason">>}.
|
||||
{<<"licenses">>,[<<"Apache-2.0">>]}.
|
||||
{<<"requirements">>,
|
||||
[[{<<"name">>,<<"decimal">>},
|
||||
{<<"app">>,<<"decimal">>},
|
||||
{<<"optional">>,true},
|
||||
{<<"requirement">>,<<"~> 1.0 or ~> 2.0">>},
|
||||
{<<"repository">>,<<"hexpm">>}]]}.
|
||||
{<<"files">>,
|
||||
[<<"lib">>,<<"lib/jason.ex">>,<<"lib/encoder.ex">>,<<"lib/decoder.ex">>,
|
||||
<<"lib/ordered_object.ex">>,<<"lib/formatter.ex">>,<<"lib/encode.ex">>,
|
||||
<<"lib/codegen.ex">>,<<"lib/helpers.ex">>,<<"lib/sigil.ex">>,
|
||||
<<"lib/fragment.ex">>,<<"mix.exs">>,<<"README.md">>,<<"LICENSE">>,
|
||||
<<"CHANGELOG.md">>]}.
|
||||
{<<"build_tools">>,[<<"mix">>]}.
|
||||
@@ -0,0 +1,139 @@
|
||||
defmodule Jason.Codegen do
|
||||
@moduledoc false
|
||||
|
||||
alias Jason.{Encode, EncodeError}
|
||||
|
||||
def jump_table(ranges, default) do
|
||||
ranges
|
||||
|> ranges_to_orddict()
|
||||
|> :array.from_orddict(default)
|
||||
|> :array.to_orddict()
|
||||
end
|
||||
|
||||
def jump_table(ranges, default, max) do
|
||||
ranges
|
||||
|> ranges_to_orddict()
|
||||
|> :array.from_orddict(default)
|
||||
|> resize(max)
|
||||
|> :array.to_orddict()
|
||||
end
|
||||
|
||||
defmacro bytecase(var, do: clauses) do
|
||||
{ranges, default, literals} = clauses_to_ranges(clauses, [], __CALLER__)
|
||||
|
||||
jump_table = jump_table(ranges, default)
|
||||
|
||||
quote do
|
||||
case unquote(var) do
|
||||
unquote(jump_table_to_clauses(jump_table, literals))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defmacro bytecase(var, max, do: clauses) do
|
||||
{ranges, default, empty} = clauses_to_ranges(clauses, [], __CALLER__)
|
||||
|
||||
jump_table = jump_table(ranges, default, max)
|
||||
|
||||
quote do
|
||||
case unquote(var) do
|
||||
unquote(jump_table_to_clauses(jump_table, empty))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def build_kv_iodata(kv, encode_args) do
|
||||
elements =
|
||||
kv
|
||||
|> Enum.map(&encode_pair(&1, encode_args))
|
||||
|> Enum.intersperse(",")
|
||||
|
||||
collapse_static(List.flatten(["{", elements] ++ ~c'}'))
|
||||
end
|
||||
|
||||
defp clauses_to_ranges([{:->, _, [[{:in, _, [byte, range]}, rest], action]} | tail], acc, env) do
|
||||
range = Macro.expand(range, env)
|
||||
clauses_to_ranges(tail, [{range, {byte, rest, action}} | acc], env)
|
||||
end
|
||||
|
||||
defp clauses_to_ranges([{:->, _, [[default, rest], action]} | tail], acc, _env) do
|
||||
{Enum.reverse(acc), {default, rest, action}, literal_clauses(tail)}
|
||||
end
|
||||
|
||||
defp literal_clauses(clauses) do
|
||||
Enum.map(clauses, fn {:->, _, [[literal], action]} ->
|
||||
{literal, action}
|
||||
end)
|
||||
end
|
||||
|
||||
defp jump_table_to_clauses([{val, {{:_, _, _}, rest, action}} | tail], empty) do
|
||||
quote do
|
||||
<<unquote(val), unquote(rest)::bits>> ->
|
||||
unquote(action)
|
||||
end ++ jump_table_to_clauses(tail, empty)
|
||||
end
|
||||
|
||||
defp jump_table_to_clauses([{val, {byte, rest, action}} | tail], empty) do
|
||||
quote do
|
||||
<<unquote(byte), unquote(rest)::bits>> when unquote(byte) === unquote(val) ->
|
||||
unquote(action)
|
||||
end ++ jump_table_to_clauses(tail, empty)
|
||||
end
|
||||
|
||||
defp jump_table_to_clauses([], literals) do
|
||||
Enum.flat_map(literals, fn {pattern, action} ->
|
||||
quote do
|
||||
unquote(pattern) ->
|
||||
unquote(action)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp resize(array, size), do: :array.resize(size, array)
|
||||
|
||||
defp ranges_to_orddict(ranges) do
|
||||
ranges
|
||||
|> Enum.flat_map(fn
|
||||
{int, value} when is_integer(int) ->
|
||||
[{int, value}]
|
||||
|
||||
{enum, value} ->
|
||||
Enum.map(enum, &{&1, value})
|
||||
end)
|
||||
|> :orddict.from_list()
|
||||
end
|
||||
|
||||
defp encode_pair({key, value}, encode_args) do
|
||||
key = IO.iodata_to_binary(Encode.key(key, &escape_key/3))
|
||||
key = "\"" <> key <> "\":"
|
||||
[key, quote(do: Encode.value(unquote(value), unquote_splicing(encode_args)))]
|
||||
end
|
||||
|
||||
defp escape_key(binary, _original, _skip) do
|
||||
check_safe_key!(binary)
|
||||
binary
|
||||
end
|
||||
|
||||
defp check_safe_key!(binary) do
|
||||
for <<(<<byte>> <- binary)>> do
|
||||
if byte > 0x7F or byte < 0x1F or byte in ~c'"\\/' do
|
||||
raise EncodeError,
|
||||
"invalid byte #{inspect(byte, base: :hex)} in literal key: #{inspect(binary)}"
|
||||
end
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp collapse_static([bin1, bin2 | rest]) when is_binary(bin1) and is_binary(bin2) do
|
||||
collapse_static([bin1 <> bin2 | rest])
|
||||
end
|
||||
|
||||
defp collapse_static([other | rest]) do
|
||||
[other | collapse_static(rest)]
|
||||
end
|
||||
|
||||
defp collapse_static([]) do
|
||||
[]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,708 @@
|
||||
defmodule Jason.DecodeError do
|
||||
@type t :: %__MODULE__{position: integer, data: String.t}
|
||||
|
||||
defexception [:position, :token, :data]
|
||||
|
||||
def message(%{position: position, token: token}) when is_binary(token) do
|
||||
"unexpected sequence at position #{position}: #{inspect token}"
|
||||
end
|
||||
def message(%{position: position, data: data}) when position == byte_size(data) do
|
||||
"unexpected end of input at position #{position}"
|
||||
end
|
||||
def message(%{position: position, data: data}) do
|
||||
byte = :binary.at(data, position)
|
||||
str = <<byte>>
|
||||
if String.printable?(str) do
|
||||
"unexpected byte at position #{position}: " <>
|
||||
"#{inspect byte, base: :hex} (#{inspect str})"
|
||||
else
|
||||
"unexpected byte at position #{position}: " <>
|
||||
"#{inspect byte, base: :hex}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Jason.Decoder do
|
||||
@moduledoc false
|
||||
|
||||
import Bitwise
|
||||
|
||||
alias Jason.{DecodeError, Codegen}
|
||||
|
||||
import Codegen, only: [bytecase: 2, bytecase: 3]
|
||||
import Record
|
||||
|
||||
@dialyzer :no_improper_lists
|
||||
|
||||
# @compile :native
|
||||
|
||||
# We use integers instead of atoms to take advantage of the jump table
|
||||
# optimization
|
||||
@terminate 0
|
||||
@array 1
|
||||
@key 2
|
||||
@object 3
|
||||
|
||||
defrecordp :decode, [keys: nil, strings: nil, objects: nil, floats: nil]
|
||||
|
||||
def parse(data, opts) when is_binary(data) do
|
||||
key_decode = key_decode_function(opts)
|
||||
string_decode = string_decode_function(opts)
|
||||
float_decode = float_decode_function(opts)
|
||||
object_decode = object_decode_function(opts)
|
||||
decode = decode(keys: key_decode, strings: string_decode, objects: object_decode, floats: float_decode)
|
||||
try do
|
||||
value(data, data, 0, [@terminate], decode)
|
||||
catch
|
||||
{:position, position} ->
|
||||
{:error, %DecodeError{position: position, data: data}}
|
||||
{:token, token, position} ->
|
||||
{:error, %DecodeError{token: token, position: position, data: data}}
|
||||
else
|
||||
value ->
|
||||
{:ok, value}
|
||||
end
|
||||
end
|
||||
|
||||
defp key_decode_function(%{keys: :atoms}), do: &String.to_atom/1
|
||||
defp key_decode_function(%{keys: :atoms!}), do: &String.to_existing_atom/1
|
||||
defp key_decode_function(%{keys: :strings}), do: &(&1)
|
||||
defp key_decode_function(%{keys: fun}) when is_function(fun, 1), do: fun
|
||||
|
||||
defp string_decode_function(%{strings: :copy}), do: &:binary.copy/1
|
||||
defp string_decode_function(%{strings: :reference}), do: &(&1)
|
||||
|
||||
defp object_decode_function(%{objects: :maps}), do: &:maps.from_list/1
|
||||
defp object_decode_function(%{objects: :ordered_objects}), do: &Jason.OrderedObject.new(:lists.reverse(&1))
|
||||
|
||||
defp float_decode_function(%{floats: :native}) do
|
||||
fn string, token, skip ->
|
||||
try do
|
||||
:erlang.binary_to_float(string)
|
||||
catch
|
||||
:error, :badarg ->
|
||||
token_error(token, skip)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if Code.ensure_loaded?(Decimal) do
|
||||
defp float_decode_function(%{floats: :decimals}) do
|
||||
fn string, token, skip ->
|
||||
try do
|
||||
Decimal.new(string)
|
||||
rescue
|
||||
Decimal.Error ->
|
||||
token_error(token, skip)
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
defp float_decode_function(%{floats: :decimals}) do
|
||||
raise ArgumentError, "decimal library not found, :decimals option not available"
|
||||
end
|
||||
end
|
||||
|
||||
defp value(data, original, skip, stack, decode) do
|
||||
bytecase data do
|
||||
_ in ~c'\s\n\t\r', rest ->
|
||||
value(rest, original, skip + 1, stack, decode)
|
||||
_ in ~c'0', rest ->
|
||||
number_zero(rest, original, skip, stack, decode, 1)
|
||||
_ in ~c'123456789', rest ->
|
||||
number(rest, original, skip, stack, decode, 1)
|
||||
_ in ~c'-', rest ->
|
||||
number_minus(rest, original, skip, stack, decode)
|
||||
_ in ~c'"', rest ->
|
||||
string(rest, original, skip + 1, stack, decode, 0)
|
||||
_ in ~c'[', rest ->
|
||||
array(rest, original, skip + 1, stack, decode)
|
||||
_ in ~c'{', rest ->
|
||||
object(rest, original, skip + 1, stack, decode)
|
||||
_ in ~c']', rest ->
|
||||
empty_array(rest, original, skip + 1, stack, decode)
|
||||
_ in ~c't', rest ->
|
||||
case rest do
|
||||
<<"rue", rest::bits>> ->
|
||||
continue(rest, original, skip + 4, stack, decode, true)
|
||||
<<_::bits>> ->
|
||||
error(original, skip)
|
||||
end
|
||||
_ in ~c'f', rest ->
|
||||
case rest do
|
||||
<<"alse", rest::bits>> ->
|
||||
continue(rest, original, skip + 5, stack, decode, false)
|
||||
<<_::bits>> ->
|
||||
error(original, skip)
|
||||
end
|
||||
_ in ~c'n', rest ->
|
||||
case rest do
|
||||
<<"ull", rest::bits>> ->
|
||||
continue(rest, original, skip + 4, stack, decode, nil)
|
||||
<<_::bits>> ->
|
||||
error(original, skip)
|
||||
end
|
||||
_, rest ->
|
||||
error(rest, original, skip + 1, stack, decode)
|
||||
<<_::bits>> ->
|
||||
error(original, skip)
|
||||
end
|
||||
end
|
||||
|
||||
defp number_minus(<<?0, rest::bits>>, original, skip, stack, decode) do
|
||||
number_zero(rest, original, skip, stack, decode, 2)
|
||||
end
|
||||
defp number_minus(<<byte, rest::bits>>, original, skip, stack, decode)
|
||||
when byte in ~c'123456789' do
|
||||
number(rest, original, skip, stack, decode, 2)
|
||||
end
|
||||
defp number_minus(<<_rest::bits>>, original, skip, _stack, _decode) do
|
||||
error(original, skip + 1)
|
||||
end
|
||||
|
||||
if function_exported?(Application, :compile_env, 3) do
|
||||
@integer_digit_limit Application.compile_env(:jason, :decoding_integer_digit_limit, 1024)
|
||||
else
|
||||
# use apply to avoid warnings in newer Elixir versions
|
||||
@integer_digit_limit apply(Application, :get_env, [:jason, :decoding_integer_digit_limit, 1024])
|
||||
end
|
||||
|
||||
defp number(<<byte, rest::bits>>, original, skip, stack, decode, len)
|
||||
when byte in ~c'0123456789' do
|
||||
number(rest, original, skip, stack, decode, len + 1)
|
||||
end
|
||||
defp number(<<?., rest::bits>>, original, skip, stack, decode, len) do
|
||||
number_frac(rest, original, skip, stack, decode, len + 1)
|
||||
end
|
||||
defp number(<<e, rest::bits>>, original, skip, stack, decode, len) when e in ~c'eE' do
|
||||
prefix = binary_part(original, skip, len)
|
||||
number_exp_copy(rest, original, skip + len + 1, stack, decode, prefix)
|
||||
end
|
||||
defp number(<<rest::bits>>, original, skip, stack, decode, len) do
|
||||
token = binary_part(original, skip, len)
|
||||
if byte_size(token) > @integer_digit_limit do
|
||||
token_error(token, skip)
|
||||
end
|
||||
int = String.to_integer(token)
|
||||
continue(rest, original, skip + len, stack, decode, int)
|
||||
end
|
||||
|
||||
defp number_frac(<<byte, rest::bits>>, original, skip, stack, decode, len)
|
||||
when byte in ~c'0123456789' do
|
||||
number_frac_cont(rest, original, skip, stack, decode, len + 1)
|
||||
end
|
||||
defp number_frac(<<_rest::bits>>, original, skip, _stack, _decode, len) do
|
||||
error(original, skip + len)
|
||||
end
|
||||
|
||||
defp number_frac_cont(<<byte, rest::bits>>, original, skip, stack, decode, len)
|
||||
when byte in ~c'0123456789' do
|
||||
number_frac_cont(rest, original, skip, stack, decode, len + 1)
|
||||
end
|
||||
defp number_frac_cont(<<e, rest::bits>>, original, skip, stack, decode, len)
|
||||
when e in ~c'eE' do
|
||||
number_exp(rest, original, skip, stack, decode, len + 1)
|
||||
end
|
||||
defp number_frac_cont(<<rest::bits>>, original, skip, stack, decode, len) do
|
||||
token = binary_part(original, skip, len)
|
||||
decode(floats: float_decode) = decode
|
||||
float = float_decode.(token, token, skip)
|
||||
continue(rest, original, skip + len, stack, decode, float)
|
||||
end
|
||||
|
||||
defp number_exp(<<byte, rest::bits>>, original, skip, stack, decode, len)
|
||||
when byte in ~c'0123456789' do
|
||||
number_exp_cont(rest, original, skip, stack, decode, len + 1)
|
||||
end
|
||||
defp number_exp(<<byte, rest::bits>>, original, skip, stack, decode, len)
|
||||
when byte in ~c'+-' do
|
||||
number_exp_sign(rest, original, skip, stack, decode, len + 1)
|
||||
end
|
||||
defp number_exp(<<_rest::bits>>, original, skip, _stack, _decode, len) do
|
||||
error(original, skip + len)
|
||||
end
|
||||
|
||||
defp number_exp_sign(<<byte, rest::bits>>, original, skip, stack, decode, len)
|
||||
when byte in ~c'0123456789' do
|
||||
number_exp_cont(rest, original, skip, stack, decode, len + 1)
|
||||
end
|
||||
defp number_exp_sign(<<_rest::bits>>, original, skip, _stack, _decode, len) do
|
||||
error(original, skip + len)
|
||||
end
|
||||
|
||||
defp number_exp_cont(<<byte, rest::bits>>, original, skip, stack, decode, len)
|
||||
when byte in ~c'0123456789' do
|
||||
number_exp_cont(rest, original, skip, stack, decode, len + 1)
|
||||
end
|
||||
defp number_exp_cont(<<rest::bits>>, original, skip, stack, decode, len) do
|
||||
token = binary_part(original, skip, len)
|
||||
decode(floats: float_decode) = decode
|
||||
float = float_decode.(token, token, skip)
|
||||
continue(rest, original, skip + len, stack, decode, float)
|
||||
end
|
||||
|
||||
defp number_exp_copy(<<byte, rest::bits>>, original, skip, stack, decode, prefix)
|
||||
when byte in ~c'0123456789' do
|
||||
number_exp_cont(rest, original, skip, stack, decode, prefix, 1)
|
||||
end
|
||||
defp number_exp_copy(<<byte, rest::bits>>, original, skip, stack, decode, prefix)
|
||||
when byte in ~c'+-' do
|
||||
number_exp_sign(rest, original, skip, stack, decode, prefix, 1)
|
||||
end
|
||||
defp number_exp_copy(<<_rest::bits>>, original, skip, _stack, _decode, _prefix) do
|
||||
error(original, skip)
|
||||
end
|
||||
|
||||
defp number_exp_sign(<<byte, rest::bits>>, original, skip, stack, decode, prefix, len)
|
||||
when byte in ~c'0123456789' do
|
||||
number_exp_cont(rest, original, skip, stack, decode, prefix, len + 1)
|
||||
end
|
||||
defp number_exp_sign(<<_rest::bits>>, original, skip, _stack, _decode, _prefix, len) do
|
||||
error(original, skip + len)
|
||||
end
|
||||
|
||||
defp number_exp_cont(<<byte, rest::bits>>, original, skip, stack, decode, prefix, len)
|
||||
when byte in ~c'0123456789' do
|
||||
number_exp_cont(rest, original, skip, stack, decode, prefix, len + 1)
|
||||
end
|
||||
defp number_exp_cont(<<rest::bits>>, original, skip, stack, decode, prefix, len) do
|
||||
suffix = binary_part(original, skip, len)
|
||||
string = prefix <> ".0e" <> suffix
|
||||
prefix_size = byte_size(prefix)
|
||||
initial_skip = skip - prefix_size - 1
|
||||
final_skip = skip + len
|
||||
token = binary_part(original, initial_skip, prefix_size + len + 1)
|
||||
decode(floats: float_decode) = decode
|
||||
float = float_decode.(string, token, initial_skip)
|
||||
continue(rest, original, final_skip, stack, decode, float)
|
||||
end
|
||||
|
||||
defp number_zero(<<?., rest::bits>>, original, skip, stack, decode, len) do
|
||||
number_frac(rest, original, skip, stack, decode, len + 1)
|
||||
end
|
||||
defp number_zero(<<e, rest::bits>>, original, skip, stack, decode, len) when e in ~c'eE' do
|
||||
number_exp_copy(rest, original, skip + len + 1, stack, decode, "0")
|
||||
end
|
||||
defp number_zero(<<rest::bits>>, original, skip, stack, decode, len) do
|
||||
continue(rest, original, skip + len, stack, decode, 0)
|
||||
end
|
||||
|
||||
@compile {:inline, array: 5}
|
||||
|
||||
defp array(rest, original, skip, stack, decode) do
|
||||
value(rest, original, skip, [@array, [] | stack], decode)
|
||||
end
|
||||
|
||||
defp empty_array(<<rest::bits>>, original, skip, stack, decode) do
|
||||
case stack do
|
||||
[@array, [] | stack] ->
|
||||
continue(rest, original, skip, stack, decode, [])
|
||||
_ ->
|
||||
error(original, skip - 1)
|
||||
end
|
||||
end
|
||||
|
||||
defp array(data, original, skip, stack, decode, value) do
|
||||
bytecase data do
|
||||
_ in ~c'\s\n\t\r', rest ->
|
||||
array(rest, original, skip + 1, stack, decode, value)
|
||||
_ in ~c']', rest ->
|
||||
[acc | stack] = stack
|
||||
value = :lists.reverse(acc, [value])
|
||||
continue(rest, original, skip + 1, stack, decode, value)
|
||||
_ in ~c',', rest ->
|
||||
[acc | stack] = stack
|
||||
value(rest, original, skip + 1, [@array, [value | acc] | stack], decode)
|
||||
_, _rest ->
|
||||
error(original, skip)
|
||||
<<_::bits>> ->
|
||||
empty_error(original, skip)
|
||||
end
|
||||
end
|
||||
|
||||
@compile {:inline, object: 5}
|
||||
|
||||
defp object(rest, original, skip, stack, decode) do
|
||||
key(rest, original, skip, [[] | stack], decode)
|
||||
end
|
||||
|
||||
defp object(data, original, skip, stack, decode, value) do
|
||||
bytecase data do
|
||||
_ in ~c'\s\n\t\r', rest ->
|
||||
object(rest, original, skip + 1, stack, decode, value)
|
||||
_ in ~c'}', rest ->
|
||||
skip = skip + 1
|
||||
[key, acc | stack] = stack
|
||||
decode(keys: key_decode) = decode
|
||||
final = [{key_decode.(key), value} | acc]
|
||||
decode(objects: object_decode) = decode
|
||||
continue(rest, original, skip, stack, decode, object_decode.(final))
|
||||
_ in ~c',', rest ->
|
||||
skip = skip + 1
|
||||
[key, acc | stack] = stack
|
||||
decode(keys: key_decode) = decode
|
||||
acc = [{key_decode.(key), value} | acc]
|
||||
key(rest, original, skip, [acc | stack], decode)
|
||||
_, _rest ->
|
||||
error(original, skip)
|
||||
<<_::bits>> ->
|
||||
empty_error(original, skip)
|
||||
end
|
||||
end
|
||||
|
||||
defp key(data, original, skip, stack, decode) do
|
||||
bytecase data do
|
||||
_ in ~c'\s\n\t\r', rest ->
|
||||
key(rest, original, skip + 1, stack, decode)
|
||||
_ in ~c'}', rest ->
|
||||
case stack do
|
||||
[[] | stack] ->
|
||||
decode(objects: object_decode) = decode
|
||||
continue(rest, original, skip + 1, stack, decode, object_decode.([]))
|
||||
_ ->
|
||||
error(original, skip)
|
||||
end
|
||||
_ in ~c'"', rest ->
|
||||
string(rest, original, skip + 1, [@key | stack], decode, 0)
|
||||
_, _rest ->
|
||||
error(original, skip)
|
||||
<<_::bits>> ->
|
||||
empty_error(original, skip)
|
||||
end
|
||||
end
|
||||
|
||||
defp key(data, original, skip, stack, decode, value) do
|
||||
bytecase data do
|
||||
_ in ~c'\s\n\t\r', rest ->
|
||||
key(rest, original, skip + 1, stack, decode, value)
|
||||
_ in ~c':', rest ->
|
||||
value(rest, original, skip + 1, [@object, value | stack], decode)
|
||||
_, _rest ->
|
||||
error(original, skip)
|
||||
<<_::bits>> ->
|
||||
empty_error(original, skip)
|
||||
end
|
||||
end
|
||||
|
||||
# TODO: check if this approach would be faster:
|
||||
# https://git.ninenines.eu/cowlib.git/tree/src/cow_ws.erl#n469
|
||||
# http://bjoern.hoehrmann.de/utf-8/decoder/dfa/
|
||||
defp string(data, original, skip, stack, decode, len) do
|
||||
bytecase data, 128 do
|
||||
_ in ~c'"', rest ->
|
||||
decode(strings: string_decode) = decode
|
||||
string = string_decode.(binary_part(original, skip, len))
|
||||
continue(rest, original, skip + len + 1, stack, decode, string)
|
||||
_ in ~c'\\', rest ->
|
||||
part = binary_part(original, skip, len)
|
||||
escape(rest, original, skip + len, stack, decode, part)
|
||||
_ in unquote(0x00..0x1F), _rest ->
|
||||
error(original, skip + len)
|
||||
_, rest ->
|
||||
string(rest, original, skip, stack, decode, len + 1)
|
||||
<<char::utf8, rest::bits>> when char <= 0x7FF ->
|
||||
string(rest, original, skip, stack, decode, len + 2)
|
||||
<<char::utf8, rest::bits>> when char <= 0xFFFF ->
|
||||
string(rest, original, skip, stack, decode, len + 3)
|
||||
<<_char::utf8, rest::bits>> ->
|
||||
string(rest, original, skip, stack, decode, len + 4)
|
||||
<<_::bits>> ->
|
||||
empty_error(original, skip + len)
|
||||
end
|
||||
end
|
||||
|
||||
defp string(data, original, skip, stack, decode, acc, len) do
|
||||
bytecase data, 128 do
|
||||
_ in ~c'"', rest ->
|
||||
last = binary_part(original, skip, len)
|
||||
string = IO.iodata_to_binary([acc | last])
|
||||
continue(rest, original, skip + len + 1, stack, decode, string)
|
||||
_ in ~c'\\', rest ->
|
||||
part = binary_part(original, skip, len)
|
||||
escape(rest, original, skip + len, stack, decode, [acc | part])
|
||||
_ in unquote(0x00..0x1F), _rest ->
|
||||
error(original, skip + len)
|
||||
_, rest ->
|
||||
string(rest, original, skip, stack, decode, acc, len + 1)
|
||||
<<char::utf8, rest::bits>> when char <= 0x7FF ->
|
||||
string(rest, original, skip, stack, decode, acc, len + 2)
|
||||
<<char::utf8, rest::bits>> when char <= 0xFFFF ->
|
||||
string(rest, original, skip, stack, decode, acc, len + 3)
|
||||
<<_char::utf8, rest::bits>> ->
|
||||
string(rest, original, skip, stack, decode, acc, len + 4)
|
||||
<<_::bits>> ->
|
||||
empty_error(original, skip + len)
|
||||
end
|
||||
end
|
||||
|
||||
defp escape(data, original, skip, stack, decode, acc) do
|
||||
bytecase data do
|
||||
_ in ~c'b', rest ->
|
||||
string(rest, original, skip + 2, stack, decode, [acc | ~c'\b'], 0)
|
||||
_ in ~c't', rest ->
|
||||
string(rest, original, skip + 2, stack, decode, [acc | ~c'\t'], 0)
|
||||
_ in ~c'n', rest ->
|
||||
string(rest, original, skip + 2, stack, decode, [acc | ~c'\n'], 0)
|
||||
_ in ~c'f', rest ->
|
||||
string(rest, original, skip + 2, stack, decode, [acc | ~c'\f'], 0)
|
||||
_ in ~c'r', rest ->
|
||||
string(rest, original, skip + 2, stack, decode, [acc | ~c'\r'], 0)
|
||||
_ in ~c'"', rest ->
|
||||
string(rest, original, skip + 2, stack, decode, [acc | ~c'\"'], 0)
|
||||
_ in ~c'/', rest ->
|
||||
string(rest, original, skip + 2, stack, decode, [acc | ~c'/'], 0)
|
||||
_ in ~c'\\', rest ->
|
||||
string(rest, original, skip + 2, stack, decode, [acc | ~c'\\'], 0)
|
||||
_ in ~c'u', rest ->
|
||||
escapeu(rest, original, skip, stack, decode, acc)
|
||||
_, _rest ->
|
||||
error(original, skip + 1)
|
||||
<<_::bits>> ->
|
||||
empty_error(original, skip)
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Unescape do
|
||||
@moduledoc false
|
||||
|
||||
import Bitwise
|
||||
|
||||
@digits Enum.concat([?0..?9, ?A..?F, ?a..?f])
|
||||
|
||||
def unicode_escapes(chars1 \\ @digits, chars2 \\ @digits) do
|
||||
for char1 <- chars1, char2 <- chars2 do
|
||||
{(char1 <<< 8) + char2, integer8(char1, char2)}
|
||||
end
|
||||
end
|
||||
|
||||
defp integer8(char1, char2) do
|
||||
(integer4(char1) <<< 4) + integer4(char2)
|
||||
end
|
||||
|
||||
defp integer4(char) when char in ?0..?9, do: char - ?0
|
||||
defp integer4(char) when char in ?A..?F, do: char - ?A + 10
|
||||
defp integer4(char) when char in ?a..?f, do: char - ?a + 10
|
||||
|
||||
defp token_error_clause(original, skip, len) do
|
||||
quote do
|
||||
_ ->
|
||||
token_error(unquote_splicing([original, skip, len]))
|
||||
end
|
||||
end
|
||||
|
||||
defmacro escapeu_first(int, last, rest, original, skip, stack, decode, acc) do
|
||||
clauses = escapeu_first_clauses(last, rest, original, skip, stack, decode, acc)
|
||||
quote location: :keep do
|
||||
case unquote(int) do
|
||||
unquote(clauses ++ token_error_clause(original, skip, 6))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp escapeu_first_clauses(last, rest, original, skip, stack, decode, acc) do
|
||||
for {int, first} <- unicode_escapes(),
|
||||
not (first in 0xDC..0xDF) do
|
||||
escapeu_first_clause(int, first, last, rest, original, skip, stack, decode, acc)
|
||||
end
|
||||
end
|
||||
|
||||
defp escapeu_first_clause(int, first, last, rest, original, skip, stack, decode, acc)
|
||||
when first in 0xD8..0xDB do
|
||||
hi =
|
||||
quote bind_quoted: [first: first, last: last] do
|
||||
0x10000 + ((((first &&& 0x03) <<< 8) + last) <<< 10)
|
||||
end
|
||||
args = [rest, original, skip, stack, decode, acc, hi]
|
||||
[clause] =
|
||||
quote location: :keep do
|
||||
unquote(int) -> escape_surrogate(unquote_splicing(args))
|
||||
end
|
||||
clause
|
||||
end
|
||||
|
||||
defp escapeu_first_clause(int, first, last, rest, original, skip, stack, decode, acc)
|
||||
when first <= 0x00 do
|
||||
skip = quote do: (unquote(skip) + 6)
|
||||
acc =
|
||||
quote bind_quoted: [acc: acc, first: first, last: last] do
|
||||
if last <= 0x7F do
|
||||
# 0?????
|
||||
[acc, last]
|
||||
else
|
||||
# 110xxxx?? 10?????
|
||||
byte1 = ((0b110 <<< 5) + (first <<< 2)) + (last >>> 6)
|
||||
byte2 = (0b10 <<< 6) + (last &&& 0b111111)
|
||||
[acc, byte1, byte2]
|
||||
end
|
||||
end
|
||||
args = [rest, original, skip, stack, decode, acc, 0]
|
||||
[clause] =
|
||||
quote location: :keep do
|
||||
unquote(int) -> string(unquote_splicing(args))
|
||||
end
|
||||
clause
|
||||
end
|
||||
|
||||
defp escapeu_first_clause(int, first, last, rest, original, skip, stack, decode, acc)
|
||||
when first <= 0x07 do
|
||||
skip = quote do: (unquote(skip) + 6)
|
||||
acc =
|
||||
quote bind_quoted: [acc: acc, first: first, last: last] do
|
||||
# 110xxx?? 10??????
|
||||
byte1 = ((0b110 <<< 5) + (first <<< 2)) + (last >>> 6)
|
||||
byte2 = (0b10 <<< 6) + (last &&& 0b111111)
|
||||
[acc, byte1, byte2]
|
||||
end
|
||||
args = [rest, original, skip, stack, decode, acc, 0]
|
||||
[clause] =
|
||||
quote location: :keep do
|
||||
unquote(int) -> string(unquote_splicing(args))
|
||||
end
|
||||
clause
|
||||
end
|
||||
|
||||
defp escapeu_first_clause(int, first, last, rest, original, skip, stack, decode, acc)
|
||||
when first <= 0xFF do
|
||||
skip = quote do: (unquote(skip) + 6)
|
||||
acc =
|
||||
quote bind_quoted: [acc: acc, first: first, last: last] do
|
||||
# 1110xxxx 10xxxx?? 10??????
|
||||
byte1 = (0b1110 <<< 4) + (first >>> 4)
|
||||
byte2 = ((0b10 <<< 6) + ((first &&& 0b1111) <<< 2)) + (last >>> 6)
|
||||
byte3 = (0b10 <<< 6) + (last &&& 0b111111)
|
||||
[acc, byte1, byte2, byte3]
|
||||
end
|
||||
args = [rest, original, skip, stack, decode, acc, 0]
|
||||
[clause] =
|
||||
quote location: :keep do
|
||||
unquote(int) -> string(unquote_splicing(args))
|
||||
end
|
||||
clause
|
||||
end
|
||||
|
||||
defmacro escapeu_last(int, original, skip) do
|
||||
clauses = escapeu_last_clauses()
|
||||
quote location: :keep do
|
||||
case unquote(int) do
|
||||
unquote(clauses ++ token_error_clause(original, skip, 6))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp escapeu_last_clauses() do
|
||||
for {int, last} <- unicode_escapes() do
|
||||
[clause] =
|
||||
quote do
|
||||
unquote(int) -> unquote(last)
|
||||
end
|
||||
clause
|
||||
end
|
||||
end
|
||||
|
||||
defmacro escapeu_surrogate(int, last, rest, original, skip, stack, decode, acc,
|
||||
hi) do
|
||||
clauses = escapeu_surrogate_clauses(last, rest, original, skip, stack, decode, acc, hi)
|
||||
quote location: :keep do
|
||||
case unquote(int) do
|
||||
unquote(clauses ++ token_error_clause(original, skip, 12))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp escapeu_surrogate_clauses(last, rest, original, skip, stack, decode, acc, hi) do
|
||||
digits1 = ~c'Dd'
|
||||
digits2 = Stream.concat([?C..?F, ?c..?f])
|
||||
for {int, first} <- unicode_escapes(digits1, digits2) do
|
||||
escapeu_surrogate_clause(int, first, last, rest, original, skip, stack, decode, acc, hi)
|
||||
end
|
||||
end
|
||||
|
||||
defp escapeu_surrogate_clause(int, first, last, rest, original, skip, stack, decode, acc, hi) do
|
||||
skip = quote do: unquote(skip) + 12
|
||||
acc =
|
||||
quote bind_quoted: [acc: acc, first: first, last: last, hi: hi] do
|
||||
lo = ((first &&& 0x03) <<< 8) + last
|
||||
[acc | <<(hi + lo)::utf8>>]
|
||||
end
|
||||
args = [rest, original, skip, stack, decode, acc, 0]
|
||||
[clause] =
|
||||
quote do
|
||||
unquote(int) ->
|
||||
string(unquote_splicing(args))
|
||||
end
|
||||
clause
|
||||
end
|
||||
end
|
||||
|
||||
defp escapeu(<<int1::16, int2::16, rest::bits>>, original, skip, stack, decode, acc) do
|
||||
require Unescape
|
||||
last = escapeu_last(int2, original, skip)
|
||||
Unescape.escapeu_first(int1, last, rest, original, skip, stack, decode, acc)
|
||||
end
|
||||
defp escapeu(<<_rest::bits>>, original, skip, _stack, _decode, _acc) do
|
||||
empty_error(original, skip)
|
||||
end
|
||||
|
||||
# @compile {:inline, escapeu_last: 3}
|
||||
|
||||
defp escapeu_last(int, original, skip) do
|
||||
require Unescape
|
||||
Unescape.escapeu_last(int, original, skip)
|
||||
end
|
||||
|
||||
defp escape_surrogate(<<?\\, ?u, int1::16, int2::16, rest::bits>>, original,
|
||||
skip, stack, decode, acc, hi) do
|
||||
require Unescape
|
||||
last = escapeu_last(int2, original, skip + 6)
|
||||
Unescape.escapeu_surrogate(int1, last, rest, original, skip, stack, decode, acc, hi)
|
||||
end
|
||||
defp escape_surrogate(<<_rest::bits>>, original, skip, _stack, _decode, _acc, _hi) do
|
||||
error(original, skip + 6)
|
||||
end
|
||||
|
||||
defp error(<<_rest::bits>>, _original, skip, _stack, _decode) do
|
||||
throw {:position, skip - 1}
|
||||
end
|
||||
|
||||
defp empty_error(_original, skip) do
|
||||
throw {:position, skip}
|
||||
end
|
||||
|
||||
@compile {:inline, error: 2, token_error: 2, token_error: 3}
|
||||
defp error(_original, skip) do
|
||||
throw {:position, skip}
|
||||
end
|
||||
|
||||
defp token_error(token, position) do
|
||||
throw {:token, token, position}
|
||||
end
|
||||
|
||||
defp token_error(token, position, len) do
|
||||
throw {:token, binary_part(token, position, len), position}
|
||||
end
|
||||
|
||||
@compile {:inline, continue: 6}
|
||||
defp continue(rest, original, skip, stack, decode, value) do
|
||||
case stack do
|
||||
[@terminate | stack] ->
|
||||
terminate(rest, original, skip, stack, decode, value)
|
||||
[@array | stack] ->
|
||||
array(rest, original, skip, stack, decode, value)
|
||||
[@key | stack] ->
|
||||
key(rest, original, skip, stack, decode, value)
|
||||
[@object | stack] ->
|
||||
object(rest, original, skip, stack, decode, value)
|
||||
end
|
||||
end
|
||||
|
||||
defp terminate(<<byte, rest::bits>>, original, skip, stack, decode, value)
|
||||
when byte in ~c'\s\n\r\t' do
|
||||
terminate(rest, original, skip + 1, stack, decode, value)
|
||||
end
|
||||
defp terminate(<<>>, _original, _skip, _stack, _decode, value) do
|
||||
value
|
||||
end
|
||||
defp terminate(<<_rest::bits>>, original, skip, _stack, _decode, _value) do
|
||||
error(original, skip)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,657 @@
|
||||
defmodule Jason.EncodeError do
|
||||
defexception [:message]
|
||||
|
||||
@type t :: %__MODULE__{message: String.t}
|
||||
|
||||
def new({:duplicate_key, key}) do
|
||||
%__MODULE__{message: "duplicate key: #{key}"}
|
||||
end
|
||||
def new({:invalid_byte, byte, original}) do
|
||||
%__MODULE__{message: "invalid byte #{inspect byte, base: :hex} in #{inspect original}"}
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Jason.Encode do
|
||||
@moduledoc """
|
||||
Utilities for encoding elixir values to JSON.
|
||||
"""
|
||||
|
||||
import Bitwise
|
||||
|
||||
alias Jason.{Codegen, EncodeError, Encoder, Fragment, OrderedObject}
|
||||
|
||||
@typep escape :: (String.t, String.t, integer -> iodata)
|
||||
@typep encode_map :: (map, escape, encode_map -> iodata)
|
||||
@opaque opts :: {escape, encode_map}
|
||||
|
||||
@dialyzer :no_improper_lists
|
||||
|
||||
# @compile :native
|
||||
|
||||
@doc false
|
||||
@spec encode(any, map) :: {:ok, iodata} | {:error, EncodeError.t | Exception.t}
|
||||
def encode(value, opts) do
|
||||
escape = escape_function(opts)
|
||||
encode_map = encode_map_function(opts)
|
||||
try do
|
||||
{:ok, value(value, escape, encode_map)}
|
||||
catch
|
||||
:throw, %EncodeError{} = e ->
|
||||
{:error, e}
|
||||
:error, %Protocol.UndefinedError{protocol: Jason.Encoder} = e ->
|
||||
{:error, e}
|
||||
end
|
||||
end
|
||||
|
||||
defp encode_map_function(%{maps: maps}) do
|
||||
case maps do
|
||||
:naive -> &map_naive/3
|
||||
:strict -> &map_strict/3
|
||||
end
|
||||
end
|
||||
|
||||
defp escape_function(%{escape: escape}) do
|
||||
case escape do
|
||||
:json -> &escape_json/3
|
||||
:html_safe -> &escape_html/3
|
||||
:unicode_safe -> &escape_unicode/3
|
||||
:javascript_safe -> &escape_javascript/3
|
||||
# Keep for compatibility with Poison
|
||||
:javascript -> &escape_javascript/3
|
||||
:unicode -> &escape_unicode/3
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Equivalent to calling the `Jason.Encoder.encode/2` protocol function.
|
||||
|
||||
Slightly more efficient for built-in types because of the internal dispatching.
|
||||
"""
|
||||
@spec value(term, opts) :: iodata
|
||||
def value(value, {escape, encode_map}) do
|
||||
value(value, escape, encode_map)
|
||||
end
|
||||
|
||||
@doc false
|
||||
# We use this directly in the helpers and deriving for extra speed
|
||||
def value(value, escape, _encode_map) when is_atom(value) do
|
||||
encode_atom(value, escape)
|
||||
end
|
||||
|
||||
def value(value, escape, _encode_map) when is_binary(value) do
|
||||
encode_string(value, escape)
|
||||
end
|
||||
|
||||
def value(value, _escape, _encode_map) when is_integer(value) do
|
||||
integer(value)
|
||||
end
|
||||
|
||||
def value(value, _escape, _encode_map) when is_float(value) do
|
||||
float(value)
|
||||
end
|
||||
|
||||
def value(value, escape, encode_map) when is_list(value) do
|
||||
list(value, escape, encode_map)
|
||||
end
|
||||
|
||||
def value(%{__struct__: module} = value, escape, encode_map) do
|
||||
struct(value, escape, encode_map, module)
|
||||
end
|
||||
|
||||
def value(value, escape, encode_map) when is_map(value) do
|
||||
case Map.to_list(value) do
|
||||
[] -> "{}"
|
||||
keyword -> encode_map.(keyword, escape, encode_map)
|
||||
end
|
||||
end
|
||||
|
||||
def value(value, escape, encode_map) do
|
||||
Encoder.encode(value, {escape, encode_map})
|
||||
end
|
||||
|
||||
@compile {:inline, integer: 1, float: 1}
|
||||
|
||||
@spec atom(atom, opts) :: iodata
|
||||
def atom(atom, {escape, _encode_map}) do
|
||||
encode_atom(atom, escape)
|
||||
end
|
||||
|
||||
defp encode_atom(nil, _escape), do: "null"
|
||||
defp encode_atom(true, _escape), do: "true"
|
||||
defp encode_atom(false, _escape), do: "false"
|
||||
defp encode_atom(atom, escape),
|
||||
do: encode_string(Atom.to_string(atom), escape)
|
||||
|
||||
@spec integer(integer) :: iodata
|
||||
def integer(integer) do
|
||||
Integer.to_string(integer)
|
||||
end
|
||||
|
||||
has_short_format = try do
|
||||
:erlang.float_to_binary(1.0, [:short])
|
||||
catch
|
||||
_, _ -> false
|
||||
else
|
||||
_ -> true
|
||||
end
|
||||
|
||||
@spec float(float) :: iodata
|
||||
if has_short_format do
|
||||
def float(float) do
|
||||
:erlang.float_to_binary(float, [:short])
|
||||
end
|
||||
else
|
||||
def float(float) do
|
||||
:io_lib_format.fwrite_g(float)
|
||||
end
|
||||
end
|
||||
|
||||
@spec list(list, opts) :: iodata
|
||||
def list(list, {escape, encode_map}) do
|
||||
list(list, escape, encode_map)
|
||||
end
|
||||
|
||||
defp list([], _escape, _encode_map) do
|
||||
"[]"
|
||||
end
|
||||
|
||||
defp list([head | tail], escape, encode_map) do
|
||||
[?[, value(head, escape, encode_map)
|
||||
| list_loop(tail, escape, encode_map)]
|
||||
end
|
||||
|
||||
defp list_loop([], _escape, _encode_map) do
|
||||
~c']'
|
||||
end
|
||||
|
||||
defp list_loop([head | tail], escape, encode_map) do
|
||||
[?,, value(head, escape, encode_map)
|
||||
| list_loop(tail, escape, encode_map)]
|
||||
end
|
||||
|
||||
@spec keyword(keyword, opts) :: iodata
|
||||
def keyword(list, _) when list == [], do: "{}"
|
||||
def keyword(list, {escape, encode_map}) when is_list(list) do
|
||||
encode_map.(list, escape, encode_map)
|
||||
end
|
||||
|
||||
@spec map(map, opts) :: iodata
|
||||
def map(value, {escape, encode_map}) do
|
||||
case Map.to_list(value) do
|
||||
[] -> "{}"
|
||||
keyword -> encode_map.(keyword, escape, encode_map)
|
||||
end
|
||||
end
|
||||
|
||||
defp map_naive([{key, value} | tail], escape, encode_map) do
|
||||
["{\"", key(key, escape), "\":",
|
||||
value(value, escape, encode_map)
|
||||
| map_naive_loop(tail, escape, encode_map)]
|
||||
end
|
||||
|
||||
defp map_naive_loop([], _escape, _encode_map) do
|
||||
~c'}'
|
||||
end
|
||||
|
||||
defp map_naive_loop([{key, value} | tail], escape, encode_map) do
|
||||
[",\"", key(key, escape), "\":",
|
||||
value(value, escape, encode_map)
|
||||
| map_naive_loop(tail, escape, encode_map)]
|
||||
end
|
||||
|
||||
defp map_strict([{key, value} | tail], escape, encode_map) do
|
||||
key = IO.iodata_to_binary(key(key, escape))
|
||||
visited = %{key => []}
|
||||
["{\"", key, "\":",
|
||||
value(value, escape, encode_map)
|
||||
| map_strict_loop(tail, escape, encode_map, visited)]
|
||||
end
|
||||
|
||||
defp map_strict_loop([], _encode_map, _escape, _visited) do
|
||||
~c'}'
|
||||
end
|
||||
|
||||
defp map_strict_loop([{key, value} | tail], escape, encode_map, visited) do
|
||||
key = IO.iodata_to_binary(key(key, escape))
|
||||
case visited do
|
||||
%{^key => _} ->
|
||||
error({:duplicate_key, key})
|
||||
_ ->
|
||||
visited = Map.put(visited, key, [])
|
||||
[",\"", key, "\":",
|
||||
value(value, escape, encode_map)
|
||||
| map_strict_loop(tail, escape, encode_map, visited)]
|
||||
end
|
||||
end
|
||||
|
||||
@spec struct(struct, opts) :: iodata
|
||||
def struct(%module{} = value, {escape, encode_map}) do
|
||||
struct(value, escape, encode_map, module)
|
||||
end
|
||||
|
||||
# TODO: benchmark the effect of inlining the to_iso8601 functions
|
||||
for module <- [Date, Time, NaiveDateTime, DateTime] do
|
||||
defp struct(value, _escape, _encode_map, unquote(module)) do
|
||||
[?", unquote(module).to_iso8601(value), ?"]
|
||||
end
|
||||
end
|
||||
|
||||
if Code.ensure_loaded?(Decimal) do
|
||||
defp struct(value, _escape, _encode_map, Decimal) do
|
||||
[?", Decimal.to_string(value, :normal), ?"]
|
||||
end
|
||||
end
|
||||
|
||||
defp struct(value, escape, encode_map, Fragment) do
|
||||
%{encode: encode} = value
|
||||
encode.({escape, encode_map})
|
||||
end
|
||||
|
||||
defp struct(value, escape, encode_map, OrderedObject) do
|
||||
case value do
|
||||
%{values: []} -> "{}"
|
||||
%{values: values} -> encode_map.(values, escape, encode_map)
|
||||
end
|
||||
end
|
||||
|
||||
defp struct(value, escape, encode_map, _module) do
|
||||
Encoder.encode(value, {escape, encode_map})
|
||||
end
|
||||
|
||||
@doc false
|
||||
# This is used in the helpers and deriving implementation
|
||||
def key(string, escape) when is_binary(string) do
|
||||
escape.(string, string, 0)
|
||||
end
|
||||
def key(atom, escape) when is_atom(atom) do
|
||||
string = Atom.to_string(atom)
|
||||
escape.(string, string, 0)
|
||||
end
|
||||
def key(other, escape) do
|
||||
string = String.Chars.to_string(other)
|
||||
escape.(string, string, 0)
|
||||
end
|
||||
|
||||
@spec string(String.t, opts) :: iodata
|
||||
def string(string, {escape, _encode_map}) do
|
||||
encode_string(string, escape)
|
||||
end
|
||||
|
||||
defp encode_string(string, escape) do
|
||||
[?", escape.(string, string, 0), ?"]
|
||||
end
|
||||
|
||||
slash_escapes = Enum.zip(~c'\b\t\n\f\r\"\\', ~c'btnfr"\\')
|
||||
surogate_escapes = Enum.zip([0x2028, 0x2029], ["\\u2028", "\\u2029"])
|
||||
ranges = [{0x00..0x1F, :unicode} | slash_escapes]
|
||||
html_ranges = [{0x00..0x1F, :unicode}, {?<, :unicode}, {?/, ?/} | slash_escapes]
|
||||
escape_jt = Codegen.jump_table(html_ranges, :error)
|
||||
|
||||
Enum.each(escape_jt, fn
|
||||
{byte, :unicode} ->
|
||||
sequence = List.to_string(:io_lib.format("\\u~4.16.0B", [byte]))
|
||||
defp escape(unquote(byte)), do: unquote(sequence)
|
||||
{byte, char} when is_integer(char) ->
|
||||
defp escape(unquote(byte)), do: unquote(<<?\\, char>>)
|
||||
{byte, :error} ->
|
||||
defp escape(unquote(byte)), do: throw(:error)
|
||||
end)
|
||||
|
||||
## regular JSON escape
|
||||
|
||||
json_jt = Codegen.jump_table(ranges, :chunk, 0x7F + 1)
|
||||
|
||||
defp escape_json(data, original, skip) do
|
||||
escape_json(data, [], original, skip)
|
||||
end
|
||||
|
||||
Enum.map(json_jt, fn
|
||||
{byte, :chunk} ->
|
||||
defp escape_json(<<byte, rest::bits>>, acc, original, skip)
|
||||
when byte === unquote(byte) do
|
||||
escape_json_chunk(rest, acc, original, skip, 1)
|
||||
end
|
||||
{byte, _escape} ->
|
||||
defp escape_json(<<byte, rest::bits>>, acc, original, skip)
|
||||
when byte === unquote(byte) do
|
||||
acc = [acc | escape(byte)]
|
||||
escape_json(rest, acc, original, skip + 1)
|
||||
end
|
||||
end)
|
||||
defp escape_json(<<char::utf8, rest::bits>>, acc, original, skip)
|
||||
when char <= 0x7FF do
|
||||
escape_json_chunk(rest, acc, original, skip, 2)
|
||||
end
|
||||
defp escape_json(<<char::utf8, rest::bits>>, acc, original, skip)
|
||||
when char <= 0xFFFF do
|
||||
escape_json_chunk(rest, acc, original, skip, 3)
|
||||
end
|
||||
defp escape_json(<<_char::utf8, rest::bits>>, acc, original, skip) do
|
||||
escape_json_chunk(rest, acc, original, skip, 4)
|
||||
end
|
||||
defp escape_json(<<>>, acc, _original, _skip) do
|
||||
acc
|
||||
end
|
||||
defp escape_json(<<byte, _rest::bits>>, _acc, original, _skip) do
|
||||
error({:invalid_byte, byte, original})
|
||||
end
|
||||
|
||||
Enum.map(json_jt, fn
|
||||
{byte, :chunk} ->
|
||||
defp escape_json_chunk(<<byte, rest::bits>>, acc, original, skip, len)
|
||||
when byte === unquote(byte) do
|
||||
escape_json_chunk(rest, acc, original, skip, len + 1)
|
||||
end
|
||||
{byte, _escape} ->
|
||||
defp escape_json_chunk(<<byte, rest::bits>>, acc, original, skip, len)
|
||||
when byte === unquote(byte) do
|
||||
part = binary_part(original, skip, len)
|
||||
acc = [acc, part | escape(byte)]
|
||||
escape_json(rest, acc, original, skip + len + 1)
|
||||
end
|
||||
end)
|
||||
defp escape_json_chunk(<<char::utf8, rest::bits>>, acc, original, skip, len)
|
||||
when char <= 0x7FF do
|
||||
escape_json_chunk(rest, acc, original, skip, len + 2)
|
||||
end
|
||||
defp escape_json_chunk(<<char::utf8, rest::bits>>, acc, original, skip, len)
|
||||
when char <= 0xFFFF do
|
||||
escape_json_chunk(rest, acc, original, skip, len + 3)
|
||||
end
|
||||
defp escape_json_chunk(<<_char::utf8, rest::bits>>, acc, original, skip, len) do
|
||||
escape_json_chunk(rest, acc, original, skip, len + 4)
|
||||
end
|
||||
defp escape_json_chunk(<<>>, acc, original, skip, len) do
|
||||
part = binary_part(original, skip, len)
|
||||
[acc | part]
|
||||
end
|
||||
defp escape_json_chunk(<<byte, _rest::bits>>, _acc, original, _skip, _len) do
|
||||
error({:invalid_byte, byte, original})
|
||||
end
|
||||
|
||||
## javascript safe JSON escape
|
||||
|
||||
defp escape_javascript(data, original, skip) do
|
||||
escape_javascript(data, [], original, skip)
|
||||
end
|
||||
|
||||
Enum.map(json_jt, fn
|
||||
{byte, :chunk} ->
|
||||
defp escape_javascript(<<byte, rest::bits>>, acc, original, skip)
|
||||
when byte === unquote(byte) do
|
||||
escape_javascript_chunk(rest, acc, original, skip, 1)
|
||||
end
|
||||
{byte, _escape} ->
|
||||
defp escape_javascript(<<byte, rest::bits>>, acc, original, skip)
|
||||
when byte === unquote(byte) do
|
||||
acc = [acc | escape(byte)]
|
||||
escape_javascript(rest, acc, original, skip + 1)
|
||||
end
|
||||
end)
|
||||
defp escape_javascript(<<char::utf8, rest::bits>>, acc, original, skip)
|
||||
when char <= 0x7FF do
|
||||
escape_javascript_chunk(rest, acc, original, skip, 2)
|
||||
end
|
||||
Enum.map(surogate_escapes, fn {byte, escape} ->
|
||||
defp escape_javascript(<<unquote(byte)::utf8, rest::bits>>, acc, original, skip) do
|
||||
acc = [acc | unquote(escape)]
|
||||
escape_javascript(rest, acc, original, skip + 3)
|
||||
end
|
||||
end)
|
||||
defp escape_javascript(<<char::utf8, rest::bits>>, acc, original, skip)
|
||||
when char <= 0xFFFF do
|
||||
escape_javascript_chunk(rest, acc, original, skip, 3)
|
||||
end
|
||||
defp escape_javascript(<<_char::utf8, rest::bits>>, acc, original, skip) do
|
||||
escape_javascript_chunk(rest, acc, original, skip, 4)
|
||||
end
|
||||
defp escape_javascript(<<>>, acc, _original, _skip) do
|
||||
acc
|
||||
end
|
||||
defp escape_javascript(<<byte, _rest::bits>>, _acc, original, _skip) do
|
||||
error({:invalid_byte, byte, original})
|
||||
end
|
||||
|
||||
Enum.map(json_jt, fn
|
||||
{byte, :chunk} ->
|
||||
defp escape_javascript_chunk(<<byte, rest::bits>>, acc, original, skip, len)
|
||||
when byte === unquote(byte) do
|
||||
escape_javascript_chunk(rest, acc, original, skip, len + 1)
|
||||
end
|
||||
{byte, _escape} ->
|
||||
defp escape_javascript_chunk(<<byte, rest::bits>>, acc, original, skip, len)
|
||||
when byte === unquote(byte) do
|
||||
part = binary_part(original, skip, len)
|
||||
acc = [acc, part | escape(byte)]
|
||||
escape_javascript(rest, acc, original, skip + len + 1)
|
||||
end
|
||||
end)
|
||||
defp escape_javascript_chunk(<<char::utf8, rest::bits>>, acc, original, skip, len)
|
||||
when char <= 0x7FF do
|
||||
escape_javascript_chunk(rest, acc, original, skip, len + 2)
|
||||
end
|
||||
Enum.map(surogate_escapes, fn {byte, escape} ->
|
||||
defp escape_javascript_chunk(<<unquote(byte)::utf8, rest::bits>>, acc, original, skip, len) do
|
||||
part = binary_part(original, skip, len)
|
||||
acc = [acc, part | unquote(escape)]
|
||||
escape_javascript(rest, acc, original, skip + len + 3)
|
||||
end
|
||||
end)
|
||||
defp escape_javascript_chunk(<<char::utf8, rest::bits>>, acc, original, skip, len)
|
||||
when char <= 0xFFFF do
|
||||
escape_javascript_chunk(rest, acc, original, skip, len + 3)
|
||||
end
|
||||
defp escape_javascript_chunk(<<_char::utf8, rest::bits>>, acc, original, skip, len) do
|
||||
escape_javascript_chunk(rest, acc, original, skip, len + 4)
|
||||
end
|
||||
defp escape_javascript_chunk(<<>>, acc, original, skip, len) do
|
||||
part = binary_part(original, skip, len)
|
||||
[acc | part]
|
||||
end
|
||||
defp escape_javascript_chunk(<<byte, _rest::bits>>, _acc, original, _skip, _len) do
|
||||
error({:invalid_byte, byte, original})
|
||||
end
|
||||
|
||||
## HTML safe JSON escape
|
||||
|
||||
html_jt = Codegen.jump_table(html_ranges, :chunk, 0x7F + 1)
|
||||
|
||||
defp escape_html(data, original, skip) do
|
||||
escape_html(data, [], original, skip)
|
||||
end
|
||||
|
||||
Enum.map(html_jt, fn
|
||||
{byte, :chunk} ->
|
||||
defp escape_html(<<byte, rest::bits>>, acc, original, skip)
|
||||
when byte === unquote(byte) do
|
||||
escape_html_chunk(rest, acc, original, skip, 1)
|
||||
end
|
||||
{byte, _escape} ->
|
||||
defp escape_html(<<byte, rest::bits>>, acc, original, skip)
|
||||
when byte === unquote(byte) do
|
||||
acc = [acc | escape(byte)]
|
||||
escape_html(rest, acc, original, skip + 1)
|
||||
end
|
||||
end)
|
||||
defp escape_html(<<char::utf8, rest::bits>>, acc, original, skip)
|
||||
when char <= 0x7FF do
|
||||
escape_html_chunk(rest, acc, original, skip, 2)
|
||||
end
|
||||
Enum.map(surogate_escapes, fn {byte, escape} ->
|
||||
defp escape_html(<<unquote(byte)::utf8, rest::bits>>, acc, original, skip) do
|
||||
acc = [acc | unquote(escape)]
|
||||
escape_html(rest, acc, original, skip + 3)
|
||||
end
|
||||
end)
|
||||
defp escape_html(<<char::utf8, rest::bits>>, acc, original, skip)
|
||||
when char <= 0xFFFF do
|
||||
escape_html_chunk(rest, acc, original, skip, 3)
|
||||
end
|
||||
defp escape_html(<<_char::utf8, rest::bits>>, acc, original, skip) do
|
||||
escape_html_chunk(rest, acc, original, skip, 4)
|
||||
end
|
||||
defp escape_html(<<>>, acc, _original, _skip) do
|
||||
acc
|
||||
end
|
||||
defp escape_html(<<byte, _rest::bits>>, _acc, original, _skip) do
|
||||
error({:invalid_byte, byte, original})
|
||||
end
|
||||
|
||||
Enum.map(html_jt, fn
|
||||
{byte, :chunk} ->
|
||||
defp escape_html_chunk(<<byte, rest::bits>>, acc, original, skip, len)
|
||||
when byte === unquote(byte) do
|
||||
escape_html_chunk(rest, acc, original, skip, len + 1)
|
||||
end
|
||||
{byte, _escape} ->
|
||||
defp escape_html_chunk(<<byte, rest::bits>>, acc, original, skip, len)
|
||||
when byte === unquote(byte) do
|
||||
part = binary_part(original, skip, len)
|
||||
acc = [acc, part | escape(byte)]
|
||||
escape_html(rest, acc, original, skip + len + 1)
|
||||
end
|
||||
end)
|
||||
defp escape_html_chunk(<<char::utf8, rest::bits>>, acc, original, skip, len)
|
||||
when char <= 0x7FF do
|
||||
escape_html_chunk(rest, acc, original, skip, len + 2)
|
||||
end
|
||||
Enum.map(surogate_escapes, fn {byte, escape} ->
|
||||
defp escape_html_chunk(<<unquote(byte)::utf8, rest::bits>>, acc, original, skip, len) do
|
||||
part = binary_part(original, skip, len)
|
||||
acc = [acc, part | unquote(escape)]
|
||||
escape_html(rest, acc, original, skip + len + 3)
|
||||
end
|
||||
end)
|
||||
defp escape_html_chunk(<<char::utf8, rest::bits>>, acc, original, skip, len)
|
||||
when char <= 0xFFFF do
|
||||
escape_html_chunk(rest, acc, original, skip, len + 3)
|
||||
end
|
||||
defp escape_html_chunk(<<_char::utf8, rest::bits>>, acc, original, skip, len) do
|
||||
escape_html_chunk(rest, acc, original, skip, len + 4)
|
||||
end
|
||||
defp escape_html_chunk(<<>>, acc, original, skip, len) do
|
||||
part = binary_part(original, skip, len)
|
||||
[acc | part]
|
||||
end
|
||||
defp escape_html_chunk(<<byte, _rest::bits>>, _acc, original, _skip, _len) do
|
||||
error({:invalid_byte, byte, original})
|
||||
end
|
||||
|
||||
## unicode escape
|
||||
|
||||
defp escape_unicode(data, original, skip) do
|
||||
escape_unicode(data, [], original, skip)
|
||||
end
|
||||
|
||||
Enum.map(json_jt, fn
|
||||
{byte, :chunk} ->
|
||||
defp escape_unicode(<<byte, rest::bits>>, acc, original, skip)
|
||||
when byte === unquote(byte) do
|
||||
escape_unicode_chunk(rest, acc, original, skip, 1)
|
||||
end
|
||||
{byte, _escape} ->
|
||||
defp escape_unicode(<<byte, rest::bits>>, acc, original, skip)
|
||||
when byte === unquote(byte) do
|
||||
acc = [acc | escape(byte)]
|
||||
escape_unicode(rest, acc, original, skip + 1)
|
||||
end
|
||||
end)
|
||||
defp escape_unicode(<<char::utf8, rest::bits>>, acc, original, skip)
|
||||
when char <= 0xFF do
|
||||
acc = [acc, "\\u00" | Integer.to_string(char, 16)]
|
||||
escape_unicode(rest, acc, original, skip + 2)
|
||||
end
|
||||
defp escape_unicode(<<char::utf8, rest::bits>>, acc, original, skip)
|
||||
when char <= 0x7FF do
|
||||
acc = [acc, "\\u0" | Integer.to_string(char, 16)]
|
||||
escape_unicode(rest, acc, original, skip + 2)
|
||||
end
|
||||
defp escape_unicode(<<char::utf8, rest::bits>>, acc, original, skip)
|
||||
when char <= 0xFFF do
|
||||
acc = [acc, "\\u0" | Integer.to_string(char, 16)]
|
||||
escape_unicode(rest, acc, original, skip + 3)
|
||||
end
|
||||
defp escape_unicode(<<char::utf8, rest::bits>>, acc, original, skip)
|
||||
when char <= 0xFFFF do
|
||||
acc = [acc, "\\u" | Integer.to_string(char, 16)]
|
||||
escape_unicode(rest, acc, original, skip + 3)
|
||||
end
|
||||
defp escape_unicode(<<char::utf8, rest::bits>>, acc, original, skip) do
|
||||
char = char - 0x10000
|
||||
acc =
|
||||
[
|
||||
acc,
|
||||
"\\uD", Integer.to_string(0x800 ||| (char >>> 10), 16),
|
||||
"\\uD" | Integer.to_string(0xC00 ||| (char &&& 0x3FF), 16)
|
||||
]
|
||||
escape_unicode(rest, acc, original, skip + 4)
|
||||
end
|
||||
defp escape_unicode(<<>>, acc, _original, _skip) do
|
||||
acc
|
||||
end
|
||||
defp escape_unicode(<<byte, _rest::bits>>, _acc, original, _skip) do
|
||||
error({:invalid_byte, byte, original})
|
||||
end
|
||||
|
||||
Enum.map(json_jt, fn
|
||||
{byte, :chunk} ->
|
||||
defp escape_unicode_chunk(<<byte, rest::bits>>, acc, original, skip, len)
|
||||
when byte === unquote(byte) do
|
||||
escape_unicode_chunk(rest, acc, original, skip, len + 1)
|
||||
end
|
||||
{byte, _escape} ->
|
||||
defp escape_unicode_chunk(<<byte, rest::bits>>, acc, original, skip, len)
|
||||
when byte === unquote(byte) do
|
||||
part = binary_part(original, skip, len)
|
||||
acc = [acc, part | escape(byte)]
|
||||
escape_unicode(rest, acc, original, skip + len + 1)
|
||||
end
|
||||
end)
|
||||
defp escape_unicode_chunk(<<char::utf8, rest::bits>>, acc, original, skip, len)
|
||||
when char <= 0xFF do
|
||||
part = binary_part(original, skip, len)
|
||||
acc = [acc, part, "\\u00" | Integer.to_string(char, 16)]
|
||||
escape_unicode(rest, acc, original, skip + len + 2)
|
||||
end
|
||||
defp escape_unicode_chunk(<<char::utf8, rest::bits>>, acc, original, skip, len)
|
||||
when char <= 0x7FF do
|
||||
part = binary_part(original, skip, len)
|
||||
acc = [acc, part, "\\u0" | Integer.to_string(char, 16)]
|
||||
escape_unicode(rest, acc, original, skip + len + 2)
|
||||
end
|
||||
defp escape_unicode_chunk(<<char::utf8, rest::bits>>, acc, original, skip, len)
|
||||
when char <= 0xFFF do
|
||||
part = binary_part(original, skip, len)
|
||||
acc = [acc, part, "\\u0" | Integer.to_string(char, 16)]
|
||||
escape_unicode(rest, acc, original, skip + len + 3)
|
||||
end
|
||||
defp escape_unicode_chunk(<<char::utf8, rest::bits>>, acc, original, skip, len)
|
||||
when char <= 0xFFFF do
|
||||
part = binary_part(original, skip, len)
|
||||
acc = [acc, part, "\\u" | Integer.to_string(char, 16)]
|
||||
escape_unicode(rest, acc, original, skip + len + 3)
|
||||
end
|
||||
defp escape_unicode_chunk(<<char::utf8, rest::bits>>, acc, original, skip, len) do
|
||||
char = char - 0x10000
|
||||
part = binary_part(original, skip, len)
|
||||
acc =
|
||||
[
|
||||
acc, part,
|
||||
"\\uD", Integer.to_string(0x800 ||| (char >>> 10), 16),
|
||||
"\\uD" | Integer.to_string(0xC00 ||| (char &&& 0x3FF), 16)
|
||||
]
|
||||
escape_unicode(rest, acc, original, skip + len + 4)
|
||||
end
|
||||
defp escape_unicode_chunk(<<>>, acc, original, skip, len) do
|
||||
part = binary_part(original, skip, len)
|
||||
[acc | part]
|
||||
end
|
||||
defp escape_unicode_chunk(<<byte, _rest::bits>>, _acc, original, _skip, _len) do
|
||||
error({:invalid_byte, byte, original})
|
||||
end
|
||||
|
||||
@compile {:inline, error: 1}
|
||||
defp error(error) do
|
||||
throw EncodeError.new(error)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,239 @@
|
||||
defprotocol Jason.Encoder do
|
||||
@moduledoc """
|
||||
Protocol controlling how a value is encoded to JSON.
|
||||
|
||||
## Deriving
|
||||
|
||||
The protocol allows leveraging the Elixir's `@derive` feature
|
||||
to simplify protocol implementation in trivial cases. Accepted
|
||||
options are:
|
||||
|
||||
* `:only` - encodes only values of specified keys.
|
||||
* `:except` - encodes all struct fields except specified keys.
|
||||
|
||||
By default all keys except the `:__struct__` key are encoded.
|
||||
|
||||
## Example
|
||||
|
||||
Let's assume a presence of the following struct:
|
||||
|
||||
defmodule Test do
|
||||
defstruct [:foo, :bar, :baz]
|
||||
end
|
||||
|
||||
If we were to call `@derive Jason.Encoder` just before `defstruct`,
|
||||
an implementation similar to the following implementation would be generated:
|
||||
|
||||
defimpl Jason.Encoder, for: Test do
|
||||
def encode(value, opts) do
|
||||
Jason.Encode.map(Map.take(value, [:foo, :bar, :baz]), opts)
|
||||
end
|
||||
end
|
||||
|
||||
If we called `@derive {Jason.Encoder, only: [:foo]}`, an implementation
|
||||
similar to the following implementation would be generated:
|
||||
|
||||
defimpl Jason.Encoder, for: Test do
|
||||
def encode(value, opts) do
|
||||
Jason.Encode.map(Map.take(value, [:foo]), opts)
|
||||
end
|
||||
end
|
||||
|
||||
If we called `@derive {Jason.Encoder, except: [:foo]}`, an implementation
|
||||
similar to the following implementation would be generated:
|
||||
|
||||
defimpl Jason.Encoder, for: Test do
|
||||
def encode(value, opts) do
|
||||
Jason.Encode.map(Map.take(value, [:bar, :baz]), opts)
|
||||
end
|
||||
end
|
||||
|
||||
The actually generated implementations are more efficient computing some data
|
||||
during compilation similar to the macros from the `Jason.Helpers` module.
|
||||
|
||||
## Explicit implementation
|
||||
|
||||
If you wish to implement the protocol fully yourself, it is advised to
|
||||
use functions from the `Jason.Encode` module to do the actual iodata
|
||||
generation - they are highly optimized and verified to always produce
|
||||
valid JSON.
|
||||
"""
|
||||
|
||||
@type t :: term
|
||||
@type opts :: Jason.Encode.opts()
|
||||
|
||||
@fallback_to_any true
|
||||
|
||||
@doc """
|
||||
Encodes `value` to JSON.
|
||||
|
||||
The argument `opts` is opaque - it can be passed to various functions in
|
||||
`Jason.Encode` (or to the protocol function itself) for encoding values to JSON.
|
||||
"""
|
||||
@spec encode(t, opts) :: iodata
|
||||
def encode(value, opts)
|
||||
end
|
||||
|
||||
defimpl Jason.Encoder, for: Any do
|
||||
defmacro __deriving__(module, struct, opts) do
|
||||
fields = fields_to_encode(struct, opts)
|
||||
kv = Enum.map(fields, &{&1, generated_var(&1)})
|
||||
escape = quote(do: escape)
|
||||
encode_map = quote(do: encode_map)
|
||||
encode_args = [escape, encode_map]
|
||||
kv_iodata = Jason.Codegen.build_kv_iodata(kv, encode_args)
|
||||
|
||||
quote do
|
||||
defimpl Jason.Encoder, for: unquote(module) do
|
||||
require Jason.Helpers
|
||||
|
||||
def encode(%{unquote_splicing(kv)}, {unquote(escape), unquote(encode_map)}) do
|
||||
unquote(kv_iodata)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# The same as Macro.var/2 except it sets generated: true and handles _ key
|
||||
defp generated_var(:_) do
|
||||
{:__, [generated: true], __MODULE__.Underscore}
|
||||
end
|
||||
defp generated_var(name) do
|
||||
{name, [generated: true], __MODULE__}
|
||||
end
|
||||
|
||||
def encode(%_{} = struct, _opts) do
|
||||
raise Protocol.UndefinedError,
|
||||
protocol: @protocol,
|
||||
value: struct,
|
||||
description: """
|
||||
Jason.Encoder protocol must always be explicitly implemented.
|
||||
|
||||
If you own the struct, you can derive the implementation specifying \
|
||||
which fields should be encoded to JSON:
|
||||
|
||||
@derive {Jason.Encoder, only: [....]}
|
||||
defstruct ...
|
||||
|
||||
It is also possible to encode all fields, although this should be \
|
||||
used carefully to avoid accidentally leaking private information \
|
||||
when new fields are added:
|
||||
|
||||
@derive Jason.Encoder
|
||||
defstruct ...
|
||||
|
||||
Finally, if you don't own the struct you want to encode to JSON, \
|
||||
you may use Protocol.derive/3 placed outside of any module:
|
||||
|
||||
Protocol.derive(Jason.Encoder, NameOfTheStruct, only: [...])
|
||||
Protocol.derive(Jason.Encoder, NameOfTheStruct)
|
||||
"""
|
||||
end
|
||||
|
||||
def encode(value, _opts) do
|
||||
raise Protocol.UndefinedError,
|
||||
protocol: @protocol,
|
||||
value: value,
|
||||
description: "Jason.Encoder protocol must always be explicitly implemented"
|
||||
end
|
||||
|
||||
defp fields_to_encode(struct, opts) do
|
||||
fields = Map.keys(struct)
|
||||
|
||||
cond do
|
||||
only = Keyword.get(opts, :only) ->
|
||||
case only -- fields do
|
||||
[] ->
|
||||
only
|
||||
|
||||
error_keys ->
|
||||
raise ArgumentError,
|
||||
"`:only` specified keys (#{inspect(error_keys)}) that are not defined in defstruct: " <>
|
||||
"#{inspect(fields -- [:__struct__])}"
|
||||
|
||||
end
|
||||
|
||||
except = Keyword.get(opts, :except) ->
|
||||
case except -- fields do
|
||||
[] ->
|
||||
fields -- [:__struct__ | except]
|
||||
|
||||
error_keys ->
|
||||
raise ArgumentError,
|
||||
"`:except` specified keys (#{inspect(error_keys)}) that are not defined in defstruct: " <>
|
||||
"#{inspect(fields -- [:__struct__])}"
|
||||
|
||||
end
|
||||
|
||||
true ->
|
||||
fields -- [:__struct__]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# The following implementations are formality - they are already covered
|
||||
# by the main encoding mechanism in Jason.Encode, but exist mostly for
|
||||
# documentation purposes and if anybody had the idea to call the protocol directly.
|
||||
|
||||
defimpl Jason.Encoder, for: Atom do
|
||||
def encode(atom, opts) do
|
||||
Jason.Encode.atom(atom, opts)
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Jason.Encoder, for: Integer do
|
||||
def encode(integer, _opts) do
|
||||
Jason.Encode.integer(integer)
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Jason.Encoder, for: Float do
|
||||
def encode(float, _opts) do
|
||||
Jason.Encode.float(float)
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Jason.Encoder, for: List do
|
||||
def encode(list, opts) do
|
||||
Jason.Encode.list(list, opts)
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Jason.Encoder, for: Map do
|
||||
def encode(map, opts) do
|
||||
Jason.Encode.map(map, opts)
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Jason.Encoder, for: BitString do
|
||||
def encode(binary, opts) when is_binary(binary) do
|
||||
Jason.Encode.string(binary, opts)
|
||||
end
|
||||
|
||||
def encode(bitstring, _opts) do
|
||||
raise Protocol.UndefinedError,
|
||||
protocol: @protocol,
|
||||
value: bitstring,
|
||||
description: "cannot encode a bitstring to JSON"
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Jason.Encoder, for: [Date, Time, NaiveDateTime, DateTime] do
|
||||
def encode(value, _opts) do
|
||||
[?", @for.to_iso8601(value), ?"]
|
||||
end
|
||||
end
|
||||
|
||||
if Code.ensure_loaded?(Decimal) do
|
||||
defimpl Jason.Encoder, for: Decimal do
|
||||
def encode(value, _opts) do
|
||||
[?", Decimal.to_string(value), ?"]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Jason.Encoder, for: Jason.Fragment do
|
||||
def encode(%{encode: encode}, opts) do
|
||||
encode.(opts)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,255 @@
|
||||
defmodule Jason.Formatter do
|
||||
@moduledoc ~S"""
|
||||
Pretty-printing and minimizing functions for JSON-encoded data.
|
||||
|
||||
Input is required to be in an 8-bit-wide encoding such as UTF-8 or Latin-1
|
||||
in `t:iodata/0` format. Input must have valid JSON, invalid JSON may produce
|
||||
unexpected results or errors.
|
||||
"""
|
||||
|
||||
@type opts :: [
|
||||
{:indent, iodata}
|
||||
| {:line_separator, iodata}
|
||||
| {:record_separator, iodata}
|
||||
| {:after_colon, iodata}
|
||||
]
|
||||
|
||||
import Record
|
||||
defrecordp :opts, [:indent, :line, :record, :colon]
|
||||
|
||||
@dialyzer :no_improper_lists
|
||||
|
||||
@doc ~S"""
|
||||
Pretty-prints JSON-encoded `input`.
|
||||
|
||||
`input` may contain multiple JSON objects or arrays, optionally separated
|
||||
by whitespace (e.g., one object per line). Objects in output will be
|
||||
separated by newlines. No trailing newline is emitted.
|
||||
|
||||
## Options
|
||||
|
||||
* `:indent` - used for nested objects and arrays (default: two spaces - `" "`);
|
||||
* `:line_separator` - used in nested objects (default: `"\n"`);
|
||||
* `:record_separator` - separates root-level objects and arrays
|
||||
(default is the value for `:line_separator` option);
|
||||
* `:after_colon` - printed after a colon inside objects (default: one space - `" "`).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Jason.Formatter.pretty_print(~s|{"a":{"b": [1, 2]}}|)
|
||||
~s|{
|
||||
"a": {
|
||||
"b": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
}
|
||||
}|
|
||||
|
||||
"""
|
||||
@spec pretty_print(iodata, opts) :: binary
|
||||
def pretty_print(input, opts \\ []) do
|
||||
input
|
||||
|> pretty_print_to_iodata(opts)
|
||||
|> IO.iodata_to_binary()
|
||||
end
|
||||
|
||||
@doc ~S"""
|
||||
Pretty-prints JSON-encoded `input` and returns iodata.
|
||||
|
||||
This function should be preferred to `pretty_print/2`, if the pretty-printed
|
||||
JSON will be handed over to one of the IO functions or sent
|
||||
over the socket. The Erlang runtime is able to leverage vectorised
|
||||
writes and avoid allocating a continuous buffer for the whole
|
||||
resulting string, lowering memory use and increasing performance.
|
||||
"""
|
||||
@spec pretty_print_to_iodata(iodata, opts) :: iodata
|
||||
def pretty_print_to_iodata(input, opts \\ []) do
|
||||
opts = parse_opts(opts, " ", "\n", nil, " ")
|
||||
|
||||
depth = :first
|
||||
empty = false
|
||||
|
||||
{output, _state} = pp_iodata(input, [], depth, empty, opts)
|
||||
|
||||
output
|
||||
end
|
||||
|
||||
@doc ~S"""
|
||||
Minimizes JSON-encoded `input`.
|
||||
|
||||
`input` may contain multiple JSON objects or arrays, optionally
|
||||
separated by whitespace (e.g., one object per line). Minimized
|
||||
output will contain one object per line. No trailing newline is emitted.
|
||||
|
||||
## Options
|
||||
|
||||
* `:record_separator` - controls the string used as newline (default: `"\n"`).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Jason.Formatter.minimize(~s|{ "a" : "b" , "c": \n\n 2}|)
|
||||
~s|{"a":"b","c":2}|
|
||||
|
||||
"""
|
||||
@spec minimize(iodata, opts) :: binary
|
||||
def minimize(input, opts \\ []) do
|
||||
input
|
||||
|> minimize_to_iodata(opts)
|
||||
|> IO.iodata_to_binary()
|
||||
end
|
||||
|
||||
@doc ~S"""
|
||||
Minimizes JSON-encoded `input` and returns iodata.
|
||||
|
||||
This function should be preferred to `minimize/2`, if the minimized
|
||||
JSON will be handed over to one of the IO functions or sent
|
||||
over the socket. The Erlang runtime is able to leverage vectorised
|
||||
writes and avoid allocating a continuous buffer for the whole
|
||||
resulting string, lowering memory use and increasing performance.
|
||||
"""
|
||||
@spec minimize_to_iodata(iodata, opts) :: iodata
|
||||
def minimize_to_iodata(input, opts) do
|
||||
record = Keyword.get(opts, :record_separator, "\n")
|
||||
opts = opts(indent: "", line: "", record: record, colon: "")
|
||||
|
||||
depth = :first
|
||||
empty = false
|
||||
|
||||
{output, _state} = pp_iodata(input, [], depth, empty, opts)
|
||||
|
||||
output
|
||||
end
|
||||
|
||||
defp parse_opts([{option, value} | opts], indent, line, record, colon) do
|
||||
value = IO.iodata_to_binary(value)
|
||||
case option do
|
||||
:indent -> parse_opts(opts, value, line, record, colon)
|
||||
:record_separator -> parse_opts(opts, indent, line, value, colon)
|
||||
:after_colon -> parse_opts(opts, indent, line, record, value)
|
||||
:line_separator -> parse_opts(opts, indent, value, record || value, colon)
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_opts([], indent, line, record, colon) do
|
||||
opts(indent: indent, line: line, record: record || line, colon: colon)
|
||||
end
|
||||
|
||||
for depth <- 1..16 do
|
||||
defp tab(" ", unquote(depth)), do: unquote(String.duplicate(" ", depth))
|
||||
end
|
||||
|
||||
defp tab("", _), do: ""
|
||||
defp tab(indent, depth), do: List.duplicate(indent, depth)
|
||||
|
||||
defp pp_iodata(<<>>, output_acc, depth, empty, opts) do
|
||||
{output_acc, &pp_iodata(&1, &2, depth, empty, opts)}
|
||||
end
|
||||
|
||||
defp pp_iodata(<<byte, rest::binary>>, output_acc, depth, empty, opts) do
|
||||
pp_byte(byte, rest, output_acc, depth, empty, opts)
|
||||
end
|
||||
|
||||
defp pp_iodata([], output_acc, depth, empty, opts) do
|
||||
{output_acc, &pp_iodata(&1, &2, depth, empty, opts)}
|
||||
end
|
||||
|
||||
defp pp_iodata([byte | rest], output_acc, depth, empty, opts) when is_integer(byte) do
|
||||
pp_byte(byte, rest, output_acc, depth, empty, opts)
|
||||
end
|
||||
|
||||
defp pp_iodata([head | tail], output_acc, depth, empty, opts) do
|
||||
{output_acc, cont} = pp_iodata(head, output_acc, depth, empty, opts)
|
||||
cont.(tail, output_acc)
|
||||
end
|
||||
|
||||
defp pp_byte(byte, rest, output, depth, empty, opts) when byte in ~c' \n\r\t' do
|
||||
pp_iodata(rest, output, depth, empty, opts)
|
||||
end
|
||||
|
||||
defp pp_byte(byte, rest, output, depth, empty, opts) when byte in ~c'{[' do
|
||||
{out, depth} =
|
||||
cond do
|
||||
depth == :first -> {byte, 1}
|
||||
depth == 0 -> {[opts(opts, :record), byte], 1}
|
||||
empty -> {[opts(opts, :line), tab(opts(opts, :indent), depth), byte], depth + 1}
|
||||
true -> {byte, depth + 1}
|
||||
end
|
||||
|
||||
empty = true
|
||||
pp_iodata(rest, [output, out], depth, empty, opts)
|
||||
end
|
||||
|
||||
defp pp_byte(byte, rest, output, depth, true = _empty, opts) when byte in ~c'}]' do
|
||||
empty = false
|
||||
depth = depth - 1
|
||||
pp_iodata(rest, [output, byte], depth, empty, opts)
|
||||
end
|
||||
|
||||
defp pp_byte(byte, rest, output, depth, false = empty, opts) when byte in ~c'}]' do
|
||||
depth = depth - 1
|
||||
out = [opts(opts, :line), tab(opts(opts, :indent), depth), byte]
|
||||
pp_iodata(rest, [output, out], depth, empty, opts)
|
||||
end
|
||||
|
||||
defp pp_byte(byte, rest, output, depth, _empty, opts) when byte in ~c',' do
|
||||
empty = false
|
||||
out = [byte, opts(opts, :line), tab(opts(opts, :indent), depth)]
|
||||
pp_iodata(rest, [output, out], depth, empty, opts)
|
||||
end
|
||||
|
||||
defp pp_byte(byte, rest, output, depth, empty, opts) when byte in ~c':' do
|
||||
out = [byte, opts(opts, :colon)]
|
||||
pp_iodata(rest, [output, out], depth, empty, opts)
|
||||
end
|
||||
|
||||
defp pp_byte(byte, rest, output, depth, empty, opts) do
|
||||
out = if empty, do: [opts(opts, :line), tab(opts(opts, :indent), depth), byte], else: byte
|
||||
empty = false
|
||||
|
||||
if byte == ?" do
|
||||
pp_string(rest, [output, out], _in_bs = false, &pp_iodata(&1, &2, depth, empty, opts))
|
||||
else
|
||||
pp_iodata(rest, [output, out], depth, empty, opts)
|
||||
end
|
||||
end
|
||||
|
||||
defp pp_string(<<>>, output_acc, in_bs, cont) do
|
||||
{output_acc, &pp_string(&1, &2, in_bs, cont)}
|
||||
end
|
||||
|
||||
defp pp_string(binary, output_acc, true = _in_bs, cont) when is_binary(binary) do
|
||||
<<byte, rest::binary>> = binary
|
||||
pp_string(rest, [output_acc, byte], false, cont)
|
||||
end
|
||||
|
||||
defp pp_string(binary, output_acc, false = _in_bs, cont) when is_binary(binary) do
|
||||
case :binary.match(binary, ["\"", "\\"]) do
|
||||
:nomatch ->
|
||||
{[output_acc | binary], &pp_string(&1, &2, false, cont)}
|
||||
{pos, 1} ->
|
||||
{head, tail} = :erlang.split_binary(binary, pos + 1)
|
||||
case :binary.at(binary, pos) do
|
||||
?\\ -> pp_string(tail, [output_acc | head], true, cont)
|
||||
?" -> cont.(tail, [output_acc | head])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp pp_string([], output_acc, in_bs, cont) do
|
||||
{output_acc, &pp_string(&1, &2, in_bs, cont)}
|
||||
end
|
||||
|
||||
defp pp_string([byte | rest], output_acc, in_bs, cont) when is_integer(byte) do
|
||||
cond do
|
||||
in_bs -> pp_string(rest, [output_acc, byte], false, cont)
|
||||
byte == ?" -> cont.(rest, [output_acc, byte])
|
||||
true -> pp_string(rest, [output_acc, byte], byte == ?\\, cont)
|
||||
end
|
||||
end
|
||||
|
||||
defp pp_string([head | tail], output_acc, in_bs, cont) do
|
||||
{output_acc, cont} = pp_string(head, output_acc, in_bs, cont)
|
||||
cont.(tail, output_acc)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
defmodule Jason.Fragment do
|
||||
@moduledoc ~S"""
|
||||
Provides a way to inject an already-encoded JSON structure into a
|
||||
to-be-encoded structure in optimized fashion.
|
||||
|
||||
This avoids a decoding/encoding round-trip for the subpart.
|
||||
|
||||
This feature can be used for caching parts of the JSON, or delegating
|
||||
the generation of the JSON to a third-party system (e.g. Postgres).
|
||||
"""
|
||||
|
||||
defstruct [:encode]
|
||||
|
||||
def new(iodata) when is_list(iodata) or is_binary(iodata) do
|
||||
%__MODULE__{encode: fn _ -> iodata end}
|
||||
end
|
||||
|
||||
def new(encode) when is_function(encode, 1) do
|
||||
%__MODULE__{encode: encode}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,98 @@
|
||||
defmodule Jason.Helpers do
|
||||
@moduledoc """
|
||||
Provides macro facilities for partial compile-time encoding of JSON.
|
||||
"""
|
||||
|
||||
alias Jason.{Codegen, Fragment}
|
||||
|
||||
@doc ~S"""
|
||||
Encodes a JSON map from a compile-time keyword.
|
||||
|
||||
Encodes the keys at compile time and strives to create as flat iodata
|
||||
structure as possible to achieve maximum efficiency. Does encoding
|
||||
right at the call site, but returns an `%Jason.Fragment{}` struct
|
||||
that needs to be passed to one of the "main" encoding functions -
|
||||
for example `Jason.encode/2` for final encoding into JSON - this
|
||||
makes it completely transparent for most uses.
|
||||
|
||||
Only allows keys that do not require escaping in any of the supported
|
||||
encoding modes. This means only ASCII characters from the range
|
||||
0x1F..0x7F excluding '\', '/' and '"' are allowed - this also excludes
|
||||
all control characters like newlines.
|
||||
|
||||
Preserves the order of the keys.
|
||||
|
||||
## Example
|
||||
|
||||
iex> fragment = json_map(foo: 1, bar: 2)
|
||||
iex> Jason.encode!(fragment)
|
||||
"{\"foo\":1,\"bar\":2}"
|
||||
|
||||
"""
|
||||
defmacro json_map(kv) do
|
||||
kv_values = Macro.expand(kv, __CALLER__)
|
||||
kv_vars = Enum.map(kv_values, fn {key, _} -> {key, generated_var(key, Codegen)} end)
|
||||
|
||||
values = Enum.map(kv_values, &elem(&1, 1))
|
||||
vars = Enum.map(kv_vars, &elem(&1, 1))
|
||||
|
||||
escape = quote(do: escape)
|
||||
encode_map = quote(do: encode_map)
|
||||
encode_args = [escape, encode_map]
|
||||
kv_iodata = Codegen.build_kv_iodata(kv_vars, encode_args)
|
||||
|
||||
quote do
|
||||
{unquote_splicing(vars)} = {unquote_splicing(values)}
|
||||
|
||||
%Fragment{
|
||||
encode: fn {unquote(escape), unquote(encode_map)} ->
|
||||
unquote(kv_iodata)
|
||||
end
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@doc ~S"""
|
||||
Encodes a JSON map from a variable containing a map and a compile-time
|
||||
list of keys.
|
||||
|
||||
It is equivalent to calling `Map.take/2` before encoding. Otherwise works
|
||||
similar to `json_map/2`.
|
||||
|
||||
## Example
|
||||
|
||||
iex> map = %{a: 1, b: 2, c: 3}
|
||||
iex> fragment = json_map_take(map, [:c, :b])
|
||||
iex> Jason.encode!(fragment)
|
||||
"{\"c\":3,\"b\":2}"
|
||||
|
||||
"""
|
||||
defmacro json_map_take(map, take) do
|
||||
take = Macro.expand(take, __CALLER__)
|
||||
kv = Enum.map(take, &{&1, generated_var(&1, Codegen)})
|
||||
escape = quote(do: escape)
|
||||
encode_map = quote(do: encode_map)
|
||||
encode_args = [escape, encode_map]
|
||||
kv_iodata = Codegen.build_kv_iodata(kv, encode_args)
|
||||
|
||||
quote do
|
||||
case unquote(map) do
|
||||
%{unquote_splicing(kv)} ->
|
||||
%Fragment{
|
||||
encode: fn {unquote(escape), unquote(encode_map)} ->
|
||||
unquote(kv_iodata)
|
||||
end
|
||||
}
|
||||
|
||||
other ->
|
||||
raise ArgumentError,
|
||||
"expected a map with keys: #{unquote(inspect(take))}, got: #{inspect(other)}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# The same as Macro.var/2 except it sets generated: true
|
||||
defp generated_var(name, context) do
|
||||
{name, [generated: true], context}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,242 @@
|
||||
defmodule Jason do
|
||||
@moduledoc """
|
||||
A blazing fast JSON parser and generator in pure Elixir.
|
||||
"""
|
||||
|
||||
alias Jason.{Encode, Decoder, DecodeError, EncodeError, Formatter}
|
||||
|
||||
@type escape :: :json | :unicode_safe | :html_safe | :javascript_safe
|
||||
@type maps :: :naive | :strict
|
||||
|
||||
@type encode_opt :: {:escape, escape} | {:maps, maps} | {:pretty, boolean | Formatter.opts()}
|
||||
|
||||
@type keys :: :atoms | :atoms! | :strings | :copy | (String.t() -> term)
|
||||
|
||||
@type strings :: :reference | :copy
|
||||
|
||||
@type floats :: :native | :decimals
|
||||
|
||||
@type objects :: :maps | :ordered_objects
|
||||
|
||||
@type decode_opt :: {:keys, keys} | {:strings, strings} | {:floats, floats} | {:objects, objects}
|
||||
|
||||
@doc """
|
||||
Parses a JSON value from `input` iodata.
|
||||
|
||||
## Options
|
||||
|
||||
* `:keys` - controls how keys in objects are decoded. Possible values are:
|
||||
|
||||
* `:strings` (default) - decodes keys as binary strings,
|
||||
* `:atoms` - keys are converted to atoms using `String.to_atom/1`,
|
||||
* `:atoms!` - keys are converted to atoms using `String.to_existing_atom/1`,
|
||||
* custom decoder - additionally a function accepting a string and returning a key
|
||||
is accepted.
|
||||
|
||||
* `:strings` - controls how strings (including keys) are decoded. Possible values are:
|
||||
|
||||
* `:reference` (default) - when possible tries to create a sub-binary into the original
|
||||
* `:copy` - always copies the strings. This option is especially useful when parts of the
|
||||
decoded data will be stored for a long time (in ets or some process) to avoid keeping
|
||||
the reference to the original data.
|
||||
|
||||
* `:floats` - controls how floats are decoded. Possible values are:
|
||||
|
||||
* `:native` (default) - Native conversion from binary to float using `:erlang.binary_to_float/1`,
|
||||
* `:decimals` - uses `Decimal.new/1` to parse the binary into a Decimal struct with arbitrary precision.
|
||||
|
||||
* `:objects` - controls how objects are decoded. Possible values are:
|
||||
|
||||
* `:maps` (default) - objects are decoded as maps
|
||||
* `:ordered_objects` - objects are decoded as `Jason.OrderedObject` structs
|
||||
|
||||
## Decoding keys to atoms
|
||||
|
||||
The `:atoms` option uses the `String.to_atom/1` call that can create atoms at runtime.
|
||||
Since the atoms are not garbage collected, this can pose a DoS attack vector when used
|
||||
on user-controlled data.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Jason.decode("{}")
|
||||
{:ok, %{}}
|
||||
|
||||
iex> Jason.decode("invalid")
|
||||
{:error, %Jason.DecodeError{data: "invalid", position: 0, token: nil}}
|
||||
"""
|
||||
@spec decode(iodata, [decode_opt]) :: {:ok, term} | {:error, DecodeError.t()}
|
||||
def decode(input, opts \\ []) do
|
||||
input = IO.iodata_to_binary(input)
|
||||
Decoder.parse(input, format_decode_opts(opts))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parses a JSON value from `input` iodata.
|
||||
|
||||
Similar to `decode/2` except it will unwrap the error tuple and raise
|
||||
in case of errors.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Jason.decode!("{}")
|
||||
%{}
|
||||
|
||||
iex> Jason.decode!("invalid")
|
||||
** (Jason.DecodeError) unexpected byte at position 0: 0x69 ("i")
|
||||
|
||||
"""
|
||||
@spec decode!(iodata, [decode_opt]) :: term | no_return
|
||||
def decode!(input, opts \\ []) do
|
||||
case decode(input, opts) do
|
||||
{:ok, result} -> result
|
||||
{:error, error} -> raise error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates JSON corresponding to `input`.
|
||||
|
||||
The generation is controlled by the `Jason.Encoder` protocol,
|
||||
please refer to the module to read more on how to define the protocol
|
||||
for custom data types.
|
||||
|
||||
## Options
|
||||
|
||||
* `:escape` - controls how strings are encoded. Possible values are:
|
||||
|
||||
* `:json` (default) - the regular JSON escaping as defined by RFC 7159.
|
||||
* `:javascript_safe` - additionally escapes the LINE SEPARATOR (U+2028)
|
||||
and PARAGRAPH SEPARATOR (U+2029) characters to make the produced JSON
|
||||
valid JavaScript.
|
||||
* `:html_safe` - similar to `:javascript_safe`, but also escapes the `/`
|
||||
character to prevent XSS.
|
||||
* `:unicode_safe` - escapes all non-ascii characters.
|
||||
|
||||
* `:maps` - controls how maps are encoded. Possible values are:
|
||||
|
||||
* `:strict` - checks the encoded map for duplicate keys and raises
|
||||
if they appear. For example `%{:foo => 1, "foo" => 2}` would be
|
||||
rejected, since both keys would be encoded to the string `"foo"`.
|
||||
* `:naive` (default) - does not perform the check.
|
||||
|
||||
* `:pretty` - controls pretty printing of the output. Possible values are:
|
||||
|
||||
* `true` to pretty print with default configuration
|
||||
* a keyword of options as specified by `Jason.Formatter.pretty_print/2`.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Jason.encode(%{a: 1})
|
||||
{:ok, ~S|{"a":1}|}
|
||||
|
||||
iex> Jason.encode("\\xFF")
|
||||
{:error, %Jason.EncodeError{message: "invalid byte 0xFF in <<255>>"}}
|
||||
|
||||
"""
|
||||
@spec encode(term, [encode_opt]) ::
|
||||
{:ok, String.t()} | {:error, EncodeError.t() | Exception.t()}
|
||||
def encode(input, opts \\ []) do
|
||||
case do_encode(input, format_encode_opts(opts)) do
|
||||
{:ok, result} -> {:ok, IO.iodata_to_binary(result)}
|
||||
{:error, error} -> {:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates JSON corresponding to `input`.
|
||||
|
||||
Similar to `encode/1` except it will unwrap the error tuple and raise
|
||||
in case of errors.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Jason.encode!(%{a: 1})
|
||||
~S|{"a":1}|
|
||||
|
||||
iex> Jason.encode!("\\xFF")
|
||||
** (Jason.EncodeError) invalid byte 0xFF in <<255>>
|
||||
|
||||
"""
|
||||
@spec encode!(term, [encode_opt]) :: String.t() | no_return
|
||||
def encode!(input, opts \\ []) do
|
||||
case do_encode(input, format_encode_opts(opts)) do
|
||||
{:ok, result} -> IO.iodata_to_binary(result)
|
||||
{:error, error} -> raise error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates JSON corresponding to `input` and returns iodata.
|
||||
|
||||
This function should be preferred to `encode/2`, if the generated
|
||||
JSON will be handed over to one of the IO functions or sent
|
||||
over the socket. The Erlang runtime is able to leverage vectorised
|
||||
writes and avoid allocating a continuous buffer for the whole
|
||||
resulting string, lowering memory use and increasing performance.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> {:ok, iodata} = Jason.encode_to_iodata(%{a: 1})
|
||||
iex> IO.iodata_to_binary(iodata)
|
||||
~S|{"a":1}|
|
||||
|
||||
iex> Jason.encode_to_iodata("\\xFF")
|
||||
{:error, %Jason.EncodeError{message: "invalid byte 0xFF in <<255>>"}}
|
||||
|
||||
"""
|
||||
@spec encode_to_iodata(term, [encode_opt]) ::
|
||||
{:ok, iodata} | {:error, EncodeError.t() | Exception.t()}
|
||||
def encode_to_iodata(input, opts \\ []) do
|
||||
do_encode(input, format_encode_opts(opts))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates JSON corresponding to `input` and returns iodata.
|
||||
|
||||
Similar to `encode_to_iodata/1` except it will unwrap the error tuple
|
||||
and raise in case of errors.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> iodata = Jason.encode_to_iodata!(%{a: 1})
|
||||
iex> IO.iodata_to_binary(iodata)
|
||||
~S|{"a":1}|
|
||||
|
||||
iex> Jason.encode_to_iodata!("\\xFF")
|
||||
** (Jason.EncodeError) invalid byte 0xFF in <<255>>
|
||||
|
||||
"""
|
||||
@spec encode_to_iodata!(term, [encode_opt]) :: iodata | no_return
|
||||
def encode_to_iodata!(input, opts \\ []) do
|
||||
case do_encode(input, format_encode_opts(opts)) do
|
||||
{:ok, result} -> result
|
||||
{:error, error} -> raise error
|
||||
end
|
||||
end
|
||||
|
||||
defp do_encode(input, %{pretty: true} = opts) do
|
||||
case Encode.encode(input, opts) do
|
||||
{:ok, encoded} -> {:ok, Formatter.pretty_print_to_iodata(encoded)}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
defp do_encode(input, %{pretty: pretty} = opts) when pretty !== false do
|
||||
case Encode.encode(input, opts) do
|
||||
{:ok, encoded} -> {:ok, Formatter.pretty_print_to_iodata(encoded, pretty)}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
defp do_encode(input, opts) do
|
||||
Encode.encode(input, opts)
|
||||
end
|
||||
|
||||
defp format_encode_opts(opts) do
|
||||
Enum.into(opts, %{escape: :json, maps: :naive})
|
||||
end
|
||||
|
||||
defp format_decode_opts(opts) do
|
||||
Enum.into(opts, %{keys: :strings, strings: :reference, floats: :native, objects: :maps})
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,94 @@
|
||||
defmodule Jason.OrderedObject do
|
||||
@doc """
|
||||
Struct implementing a JSON object retaining order of properties.
|
||||
|
||||
A wrapper around a keyword (that supports non-atom keys) allowing for
|
||||
proper protocol implementations.
|
||||
|
||||
Implements the `Access` behaviour and `Enumerable` protocol with
|
||||
complexity similar to keywords/lists.
|
||||
"""
|
||||
|
||||
@behaviour Access
|
||||
|
||||
@type t :: %__MODULE__{values: [{String.Chars.t(), term()}]}
|
||||
|
||||
defstruct values: []
|
||||
|
||||
def new(values) when is_list(values) do
|
||||
%__MODULE__{values: values}
|
||||
end
|
||||
|
||||
@impl Access
|
||||
def fetch(%__MODULE__{values: values}, key) do
|
||||
case :lists.keyfind(key, 1, values) do
|
||||
{_, value} -> {:ok, value}
|
||||
false -> :error
|
||||
end
|
||||
end
|
||||
|
||||
@impl Access
|
||||
def get_and_update(%__MODULE__{values: values} = obj, key, function) do
|
||||
{result, new_values} = get_and_update(values, [], key, function)
|
||||
{result, %{obj | values: new_values}}
|
||||
end
|
||||
|
||||
@impl Access
|
||||
def pop(%__MODULE__{values: values} = obj, key, default \\ nil) do
|
||||
case :lists.keyfind(key, 1, values) do
|
||||
{_, value} -> {value, %{obj | values: delete_key(values, key)}}
|
||||
false -> {default, obj}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_and_update([{key, current} | t], acc, key, fun) do
|
||||
case fun.(current) do
|
||||
{get, value} ->
|
||||
{get, :lists.reverse(acc, [{key, value} | t])}
|
||||
|
||||
:pop ->
|
||||
{current, :lists.reverse(acc, t)}
|
||||
|
||||
other ->
|
||||
raise "the given function must return a two-element tuple or :pop, got: #{inspect(other)}"
|
||||
end
|
||||
end
|
||||
|
||||
defp get_and_update([{_, _} = h | t], acc, key, fun), do: get_and_update(t, [h | acc], key, fun)
|
||||
|
||||
defp get_and_update([], acc, key, fun) do
|
||||
case fun.(nil) do
|
||||
{get, update} ->
|
||||
{get, [{key, update} | :lists.reverse(acc)]}
|
||||
|
||||
:pop ->
|
||||
{nil, :lists.reverse(acc)}
|
||||
|
||||
other ->
|
||||
raise "the given function must return a two-element tuple or :pop, got: #{inspect(other)}"
|
||||
end
|
||||
end
|
||||
|
||||
defp delete_key([{key, _} | tail], key), do: delete_key(tail, key)
|
||||
defp delete_key([{_, _} = pair | tail], key), do: [pair | delete_key(tail, key)]
|
||||
defp delete_key([], _key), do: []
|
||||
end
|
||||
|
||||
defimpl Enumerable, for: Jason.OrderedObject do
|
||||
def count(%{values: []}), do: {:ok, 0}
|
||||
def count(_obj), do: {:error, __MODULE__}
|
||||
|
||||
def member?(%{values: []}, _value), do: {:ok, false}
|
||||
def member?(_obj, _value), do: {:error, __MODULE__}
|
||||
|
||||
def slice(%{values: []}), do: {:ok, 0, fn _, _ -> [] end}
|
||||
def slice(_obj), do: {:error, __MODULE__}
|
||||
|
||||
def reduce(%{values: values}, acc, fun), do: Enumerable.List.reduce(values, acc, fun)
|
||||
end
|
||||
|
||||
defimpl Jason.Encoder, for: Jason.OrderedObject do
|
||||
def encode(%{values: values}, opts) do
|
||||
Jason.Encode.keyword(values, opts)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
defmodule Jason.Sigil do
|
||||
@doc ~S"""
|
||||
Handles the sigil `~j` for JSON strings.
|
||||
|
||||
Calls `Jason.decode!/2` with modifiers mapped to options.
|
||||
|
||||
Given a string literal without interpolations, decodes the
|
||||
string at compile-time.
|
||||
|
||||
## Modifiers
|
||||
|
||||
See `Jason.decode/2` for detailed descriptions.
|
||||
|
||||
* `a` - equivalent to `{:keys, :atoms}` option
|
||||
* `A` - equivalent to `{:keys, :atoms!}` option
|
||||
* `r` - equivalent to `{:strings, :reference}` option
|
||||
* `c` - equivalent to `{:strings, :copy}` option
|
||||
|
||||
## Examples
|
||||
|
||||
iex> ~j"0"
|
||||
0
|
||||
|
||||
iex> ~j"[1, 2, 3]"
|
||||
[1, 2, 3]
|
||||
|
||||
iex> ~j'"string"'r
|
||||
"string"
|
||||
|
||||
iex> ~j"{}"
|
||||
%{}
|
||||
|
||||
iex> ~j'{"atom": "value"}'a
|
||||
%{atom: "value"}
|
||||
|
||||
iex> ~j'{"#{:j}": #{~c'"j"'}}'A
|
||||
%{j: "j"}
|
||||
|
||||
"""
|
||||
defmacro sigil_j(term, modifiers)
|
||||
|
||||
defmacro sigil_j({:<<>>, _meta, [string]}, modifiers) when is_binary(string) do
|
||||
Macro.escape(Jason.decode!(string, mods_to_opts(modifiers)))
|
||||
end
|
||||
|
||||
defmacro sigil_j(term, modifiers) do
|
||||
quote(do: Jason.decode!(unquote(term), unquote(mods_to_opts(modifiers))))
|
||||
end
|
||||
|
||||
@doc ~S"""
|
||||
Handles the sigil `~J` for raw JSON strings.
|
||||
|
||||
Decodes a raw string ignoring Elixir interpolations and
|
||||
escape characters at compile-time.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> ~J'"#{string}"'
|
||||
"\#{string}"
|
||||
|
||||
iex> ~J'"\u0078\\y"'
|
||||
"x\\y"
|
||||
|
||||
iex> ~J'{"#{key}": "#{}"}'a
|
||||
%{"\#{key}": "\#{}"}
|
||||
"""
|
||||
defmacro sigil_J(term, modifiers)
|
||||
|
||||
defmacro sigil_J({:<<>>, _meta, [string]}, modifiers) when is_binary(string) do
|
||||
Macro.escape(Jason.decode!(string, mods_to_opts(modifiers)))
|
||||
end
|
||||
|
||||
@spec mods_to_opts(charlist) :: [Jason.decode_opt()]
|
||||
defp mods_to_opts(modifiers) do
|
||||
modifiers
|
||||
|> Enum.map(fn
|
||||
?a -> {:keys, :atoms}
|
||||
?A -> {:keys, :atoms!}
|
||||
?r -> {:strings, :reference}
|
||||
?c -> {:strings, :copy}
|
||||
m -> raise ArgumentError, "unknown sigil modifier #{<<?", m, ?">>}"
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,75 @@
|
||||
defmodule Jason.Mixfile do
|
||||
use Mix.Project
|
||||
|
||||
@source_url "https://github.com/michalmuskala/jason"
|
||||
@version "1.4.4"
|
||||
|
||||
def project() do
|
||||
[
|
||||
app: :jason,
|
||||
version: @version,
|
||||
elixir: "~> 1.4",
|
||||
start_permanent: Mix.env() == :prod,
|
||||
consolidate_protocols: Mix.env() != :test,
|
||||
deps: deps(),
|
||||
preferred_cli_env: [docs: :docs],
|
||||
dialyzer: dialyzer(),
|
||||
description: description(),
|
||||
package: package(),
|
||||
docs: docs()
|
||||
]
|
||||
end
|
||||
|
||||
def application() do
|
||||
[
|
||||
extra_applications: []
|
||||
]
|
||||
end
|
||||
|
||||
defp deps() do
|
||||
[
|
||||
{:decimal, "~> 1.0 or ~> 2.0", optional: true},
|
||||
{:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false},
|
||||
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false},
|
||||
] ++ maybe_stream_data()
|
||||
end
|
||||
|
||||
defp maybe_stream_data() do
|
||||
if Version.match?(System.version(), "~> 1.12") do
|
||||
[{:stream_data, "~> 1.0", only: :test}]
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
defp dialyzer() do
|
||||
[
|
||||
plt_add_apps: [:decimal]
|
||||
]
|
||||
end
|
||||
|
||||
defp description() do
|
||||
"""
|
||||
A blazing fast JSON parser and generator in pure Elixir.
|
||||
"""
|
||||
end
|
||||
|
||||
defp package() do
|
||||
[
|
||||
maintainers: ["Michał Muskała"],
|
||||
licenses: ["Apache-2.0"],
|
||||
links: %{"GitHub" => @source_url}
|
||||
]
|
||||
end
|
||||
|
||||
defp docs() do
|
||||
[
|
||||
main: "readme",
|
||||
name: "Jason",
|
||||
source_ref: "v#{@version}",
|
||||
canonical: "http://hexdocs.pm/jason",
|
||||
source_url: @source_url,
|
||||
extras: ["README.md", "CHANGELOG.md", "LICENSE"]
|
||||
]
|
||||
end
|
||||
end
|
||||
Binary file not shown.
@@ -0,0 +1,425 @@
|
||||
# Changelog
|
||||
|
||||
## 0.9.9 - 2021-06-14
|
||||
|
||||
- Switch to using `:ssl.handshake` instead of `:ssl.ssl_accept`
|
||||
for OTP 24 compatibility.
|
||||
|
||||
## 0.9.8 - 2021-03-02
|
||||
|
||||
- Ease the requirement on the gen_state_machine dependency such
|
||||
that is accept gen_state_machine 3.0.0
|
||||
|
||||
## 0.9.6 - 2020-12-01
|
||||
|
||||
- Accepted some open PRs that has been waiting for a long while. These
|
||||
include allowing the user to pass in options to connections, and
|
||||
will categorize a connection error as "connectivity", such that
|
||||
tortoise will try to reconnect with back-off.
|
||||
|
||||
## 0.9.5 - 2020-10-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Allow a timeout on async publish; this will allow the user to
|
||||
override the default timeout set when retrieving the connection;
|
||||
Note that this timeout will not work for the publish itself.
|
||||
|
||||
## 0.9.4 - 2018-11-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- A connection timeout is now categorized as a connectivity
|
||||
issue. Thanks to [Riccardo Binetti](https://github.com/rbino) and
|
||||
[Frank Hunleth](https://github.com/fhunleth) for this contribution.
|
||||
|
||||
|
||||
## 0.9.3 - 2018-11-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pass on connection errors so we can back-off on connection timeouts.
|
||||
|
||||
## 0.9.2 - 2018-09-01
|
||||
|
||||
### Added
|
||||
|
||||
- A use-macro has been added to `Tortoise.Handler` which implements
|
||||
default behaviour for the callbacks so the user only have to
|
||||
implement the callbacks they care about. This should improve
|
||||
usability of the project quite a bit.
|
||||
|
||||
## 0.9.1 - 2018-08-31
|
||||
|
||||
### Fixed
|
||||
|
||||
- Avoid a possible race condition that could happen when a connection
|
||||
was requested while the connection was in the process of being
|
||||
established. This update should avoid hanging processes when a
|
||||
publish is attempted in the small window between going from
|
||||
connecting to connected.
|
||||
|
||||
### Added
|
||||
|
||||
- A usage example was added to the README-file. Thanks, [Tanweer
|
||||
Shahzaad](https://github.com/tanweerdev).
|
||||
|
||||
## 0.9.0 - 2018-08-29
|
||||
|
||||
### Changed
|
||||
|
||||
- The `Tortoise` module does no longer delegate the `subscribe`,
|
||||
`unsubscribe`, and their `_sync` variants to the
|
||||
`Tortoise.Connection` module. They should just be accessed directly
|
||||
on the `Tortoise.Connection` module instead. This is done to make
|
||||
the documentation efforts easier, because conceptually the
|
||||
subscription belongs to the connection.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improvements has been made to the type specs and the documentation
|
||||
of various modules.
|
||||
|
||||
### Added
|
||||
|
||||
- A `ping/1` and `ping_sync/2` function has been added to the
|
||||
`Tortoise.Connection` module. This allow the user to ping the broker
|
||||
to check the connection. Notice that the connection process will
|
||||
still send ping messages to fulfill the connection keep alive
|
||||
timeout interval. There is no need to manually ping an open
|
||||
connection.
|
||||
|
||||
## 0.8.3 - 2018-08-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Upgraded to a more recent version of dialyxir and made the project
|
||||
pass the dialyzer.
|
||||
|
||||
## 0.8.2 - 2018-08-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- As according to the specification, an unsubscribe, and a subscribe
|
||||
package can no longer be encoded if it has no topic filters defined.
|
||||
|
||||
- Got rid of TravisCI in favor of SemaphoreCI. Hopefully this will
|
||||
provide more reliable CI, and a funner experience for people working
|
||||
on Tortoise.
|
||||
|
||||
## 0.8.1 - 2018-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- `disconnect/1` has been added to the `Tortoise.Connection`
|
||||
module. Given a `client_id` it will close down the connection
|
||||
cleanly; in flight messages will get canceled and a disconnect
|
||||
package will be send to the broker.
|
||||
|
||||
## 0.8.0 - 2018-08-19
|
||||
|
||||
### Changed
|
||||
|
||||
- When a connection drop the inflight manager will now re-transmit
|
||||
packages with quality of service that was inflight when the regain a
|
||||
connection.
|
||||
|
||||
- Improved the logic for handling incoming publishes with Quality of
|
||||
Service 2 (exactly once delivery). The inflight manager will ensure
|
||||
we only handle the message once (as we should); possible duplicate
|
||||
messages caused by retransmisions should get trapped and not
|
||||
forwarded to the user defined publish handler.
|
||||
|
||||
- It is now possible to pass in a binary/string as the host for a
|
||||
connection using the `Tortoise.Transport.SSL` module; it will be
|
||||
coerced to a charlist.
|
||||
|
||||
### Added
|
||||
|
||||
- The documentation on how to connect to a broker using the
|
||||
`Tortoise.Transport.SSL` method has been improved by [Bram
|
||||
Verburg](https://github.com/voltone).
|
||||
|
||||
## 0.7.0 - 2018-08-09
|
||||
|
||||
### Changed
|
||||
|
||||
- The controller will stop with an `{:protocol_violation,
|
||||
{:unexpected_package_from_remote, package}}` if the server send a
|
||||
package of the type `package`. This is the right thing to do because
|
||||
servers should implement the protocol proper, and clients should not
|
||||
accept unexpected packages.
|
||||
|
||||
- Instead of using the Logger module to log the ping response times
|
||||
from the connection keep alive messages the ping response times are
|
||||
not dispatched using a the ETS based PubSub. This will allow the
|
||||
user to log, or do whatever they want with the ping response time.
|
||||
|
||||
- Expose connection status changes via the `Tortoise.Events` pubsub
|
||||
making it possible to implement custom behavior when the connection
|
||||
goes down and when it becomes available. Great for setting up
|
||||
alarms.
|
||||
|
||||
- The receiver will now broadcast a "status down" message on the
|
||||
`Tortoise.Events` pubsub, and the connection process will listen for
|
||||
that message and enter the reconnect on status change.
|
||||
|
||||
### Removed
|
||||
|
||||
- `Tortoise.Connection.reconnect/1` has been removed as it is has been
|
||||
replaced with the pubsub based status-down message approach
|
||||
described in the "changed"-section. It might make a return at some
|
||||
point, but for now it has been removed.
|
||||
|
||||
## 0.6.0 - 2018-07-29
|
||||
|
||||
### Changed
|
||||
|
||||
- Keep the active connection in a ETS based registry, allowing
|
||||
processes that publish messages to the wire, using either
|
||||
`Tortoise.publish/4` or `Tortoise.Pipe.publish/4` to obtain the
|
||||
network connection and shoot the message directly on the network
|
||||
socket. With this change we can also error out if a process tries to
|
||||
send on a non-existing connection.
|
||||
|
||||
- The implementation for `Tortoise.publish/4` and
|
||||
`Tortoise.publish_sync/4` has been moved to the `Tortoise` module
|
||||
itself and is therefore no longer delegated to the
|
||||
`Tortoise.Connection` module. This changes the interface a bit but
|
||||
makes for a cleaner interface.
|
||||
|
||||
### Added
|
||||
|
||||
- This release introduces a `Tortoise.Events` module that implements a
|
||||
`Registry` based PubSub. Tortoise will use this to dispatch system
|
||||
events to listeners. For now it is used to dispatch new network
|
||||
connections, which is currently used by `Tortoise.publish`, the
|
||||
`Tortoise.Connection.Inflight` process, and the `Tortoise.Pipe`s. In
|
||||
the future we might add more message types to `Tortoise.Events`.
|
||||
|
||||
- Tests for `Tortoise.publish/4` and `Tortoise.publish_sync/4`
|
||||
|
||||
### Removed
|
||||
|
||||
- The `Tortoise.Connection.Transmitter` process is no longer needed,
|
||||
so it has been removed.
|
||||
|
||||
- Some dead code in the `Tortoise.Connection.Inflight` module has been
|
||||
removed. This should not change anything user facing.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A server sending a ping request to a client is now considered a
|
||||
protocol violation, as specified by both the MQTT 3.1.1 and MQTT 5
|
||||
specifications.
|
||||
|
||||
- The connection process will now cancel the keep alive timer if it
|
||||
goes offline. Previously it would terminate itself because it would
|
||||
not get the ping response from the server.
|
||||
|
||||
- Regression: The receiver will no longer crash on an assertion when
|
||||
it request a reconnect from the `Tortoise.Connection` process.
|
||||
|
||||
- The specified Elixir version in the mix.exs file should now allow
|
||||
more versions of Elixir without warnings.
|
||||
|
||||
## 0.5.1 - 2018-07-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- The receiver could report an invalid header length protocol
|
||||
violation when the server send too few bytes, and thus taking too
|
||||
long sending the complete package header. This patch will ensure we
|
||||
only report invalid header length when we have pulled more than 40
|
||||
bits in.
|
||||
|
||||
## 0.5.0 - 2018-07-18
|
||||
|
||||
### Added
|
||||
|
||||
- Documentation. Lot's of documentation. It might be a bit wordy, and
|
||||
it might repeat itself here and there, but it is a start that can be
|
||||
improved upon and good enough for a 0.4.4 release.
|
||||
|
||||
- Added some articles that describe usage of Tortoise and MQTT.
|
||||
|
||||
### Changed
|
||||
|
||||
- rename `renew/1` to `reconnect/1` on `Tortoise.Connection`-module.
|
||||
|
||||
- Some type specs has been altered to better fit reality.
|
||||
|
||||
- The `Tortoise.Transport.Tcp` transport will now accept a binary as a
|
||||
host name. Internally it will cast it to a char list.
|
||||
|
||||
## 0.4.3 - 2018-07-17
|
||||
|
||||
### Changed
|
||||
|
||||
- The wrong field was pulled out of the configuration options when the
|
||||
last will message was set, so it was impossible to configure a last
|
||||
will message. The last will message can now be set by passing in a
|
||||
`%Tortoise.Package.Publish{}` struct as the `:will` when starting a
|
||||
connection.
|
||||
|
||||
## 0.4.2 - 2018-07-08
|
||||
|
||||
### Changed
|
||||
|
||||
- The `Tortoise.Connection.renew/1` will now return `:ok` on success;
|
||||
allowing the `Torotise.Connection.Receiver` to not crash on its
|
||||
assertion when it request a reconnect.
|
||||
|
||||
## 0.4.1 - 2018-07-08
|
||||
|
||||
### Changed
|
||||
|
||||
- Tortoise should now survive the server it is connected to being
|
||||
restarted. `{:error, :econnrefused}` and `{:error, :closed}` has
|
||||
been added to the errors that make tortoise attempt a reconnect.
|
||||
|
||||
## 0.4.0 - 2018-07-08
|
||||
|
||||
### Added
|
||||
|
||||
- Incremental backoff has been added to the connector, allowing us to
|
||||
retry reconnecting to the broker if the initial (or later reconnect)
|
||||
attempt fails. The backoff will per default start retrying after 100
|
||||
ms and it will increment in multiples of 2 up until 30 seconds, at
|
||||
which point it will flip back to 100 ms and start over. This should
|
||||
ensure that we will be able to connect fairly quickly if it is a
|
||||
network fluke (or the network devise is not ready yet), and still
|
||||
not try *too often* or *too quickly*.
|
||||
|
||||
The backoff can be configured by passing `backoff` containing a
|
||||
keyword list to the connection specification. Example `backoff:
|
||||
[min_interval: 100, max_interval: 30_000]`. Both times are in
|
||||
milliseconds.
|
||||
|
||||
### Changed
|
||||
|
||||
- The code for establishing a connection and eventually reconnecting
|
||||
has been combined into one. This makes it easier to test and verify,
|
||||
and it will make it easier to handle connection errors.
|
||||
|
||||
Because the initial connection is happening outside of the `init/1`
|
||||
function the possible return values of the
|
||||
`Tortoise.Connection.start_link/1`-function has changed a bit. A
|
||||
fatal error will cause the connection process to exit instead
|
||||
because the init will always return `{:ok, state}`. This might break
|
||||
some implementation using Tortoise.
|
||||
|
||||
For now it is only `{:error, :nxdomain}` that is handled with
|
||||
connection retries. Error categorization has been planned so we can
|
||||
error out if it is a non-recoverable error reason (such as no cacert
|
||||
files specified), instead of retrying the connection. In the near
|
||||
future more error reasons will be handled with reconnect attempts.
|
||||
|
||||
- A protocol violation from the server during connection will be
|
||||
handled better; previously it would error with a decoding error,
|
||||
because it would attempt to decode 4 random bytes. The error message
|
||||
should be obvious now.
|
||||
|
||||
## 0.3.0 - 2018-06-10
|
||||
|
||||
### Added
|
||||
|
||||
- Thanks to [Troels Brødsgaard](https://github.com/trarbr) Tortoise
|
||||
now implement a module for its registry. This is found in
|
||||
`Tortoise.Registry`.
|
||||
|
||||
- The user defined controller handler callback module now accept "next
|
||||
actions" in the return tuple; this allow the user to specify that a
|
||||
topic should get subscribed to, or unsubscribed from, by specifying
|
||||
a return like `{:ok, new_state, [{:subscribe, "foo/bar", qos: 3},
|
||||
{:unsubscribe, "baz/quux"}]}`.
|
||||
|
||||
This is needed as the controller must not be blocked, and the user
|
||||
defined callback module run in the context of the controller. By
|
||||
allowing next actions like this the user can subscribe and
|
||||
unsubscribe to topics when certain events happen.
|
||||
|
||||
- The test coverage tool will now ignore modules found in the
|
||||
*lib/tortoise/handlers/*-folder. These modules implement the
|
||||
`Tortoise.Handler`-behaviour, so they should be good.
|
||||
|
||||
### Changed
|
||||
|
||||
- `Tortoise.subscribe/3` is now async, so a message will get sent to
|
||||
the mailbox of the calling process. The old behavior can be found in
|
||||
the newly created `Tortoise.subscribe_sync/3` that will block until
|
||||
the server has acknowledged the subscribe.
|
||||
|
||||
- `Tortoise.unsubscribe/3` is now also async, so like the subscribe a
|
||||
message will get sent to the mailbox of the calling process. The old
|
||||
behavior can be found in the newly added
|
||||
`Tortoise.unsubscribe_sync/3` that will block until the server has
|
||||
acknowledged the subscribe.
|
||||
|
||||
- A major refactorization of the code handling the logic running the
|
||||
user defined controller callbacks has been lifted from the
|
||||
`Tortoise.Connection.Controller` and put into the `Tortoise.Handler`
|
||||
module. This change made it possible to support the next actions,
|
||||
and makes it much easier test and add new next action types in the
|
||||
future.
|
||||
|
||||
## 0.2.2 - 2018-05-29
|
||||
|
||||
### Changed
|
||||
|
||||
- Fix an issue where larger messages would crash the receiver. It has
|
||||
been fixed and tested with messages as large as 268435455 bytes;
|
||||
which is a pretty big MQTT message.
|
||||
|
||||
## 0.2.1 - 2018-05-29
|
||||
|
||||
### Added
|
||||
|
||||
- The `Tortoise.Transport.SSL` will now pass in `[verify:
|
||||
:verify_peer]` as the default option when connecting. This will
|
||||
guide the user to pass in a list of trusted CA certificates, such as
|
||||
one provided by the Certifi package, or opt out of this by passing
|
||||
the `verify: :verify_none` option; this will make it hard for the
|
||||
user to make unsafe choices.
|
||||
|
||||
Thanks to [Bram Verburg](https://github.com/voltone) for this
|
||||
improvement.
|
||||
|
||||
## 0.2.0 - 2018-05-28
|
||||
|
||||
### Added
|
||||
|
||||
- Experimental SSL support, please try it out and provide feedback.
|
||||
|
||||
- Abstract the network communication into a behaviour called
|
||||
`Tortoise.Transport`. This behaviour specify callbacks needed to
|
||||
connect, receive, and send messages using a network transport. It
|
||||
also specify setting and getting options, as well as listening using
|
||||
that network transport; the latter part is done so they can be used
|
||||
in integration tests.
|
||||
|
||||
- A TCP transport has been created for communicating with a broker
|
||||
using TCP. Use `Tortoise.Transport.Tcp` when specifying the server
|
||||
in the connection to use the TCP transport.
|
||||
|
||||
- A SSL transport `Tortoise.Transport.SSL` has been added to the
|
||||
project allowing us to connect to a broker using an encrypted
|
||||
connection.
|
||||
|
||||
### Removed
|
||||
|
||||
- The `{:tcp, 'localhost', 1883}` connection specification has been
|
||||
removed in favor of `{Tortoise.Transport.Tcp, host: 'localhost',
|
||||
port: 1883}`. This is done because we support multiple transport
|
||||
types now, such as the `Tortoise.Transport.SSL` type (which also
|
||||
takes a `key` and a `cert` option). The format is `{transport,
|
||||
opts}`.
|
||||
|
||||
## 0.1.0 - 2018-05-21
|
||||
|
||||
### Added
|
||||
- The project is now on Hex which will hopefully broaden the user
|
||||
base. Future changes will be logged to this file.
|
||||
|
||||
- We will from now on update the version number following Semantic
|
||||
Versioning, and major changes should get logged to this file.
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "{}"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2018 Martin Gausby
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,186 @@
|
||||
# Tortoise
|
||||
|
||||
[](https://semaphoreci.com/gausby/tortoise)
|
||||
[](https://github.com/gausby/tortoise/blob/master/LICENSE)
|
||||
[](https://hex.pm/packages/tortoise)
|
||||
[](https://coveralls.io/github/gausby/tortoise?branch=master)
|
||||
|
||||
A MQTT Client application that keep connections to one or more MQTT
|
||||
brokers, handles subscriptions, and expose a publisher for publishing
|
||||
messages to the broker.
|
||||
|
||||
Amongst other things Tortoise supports:
|
||||
|
||||
- Keeping a connection to a MQTT server (version 3.1.1 for now)
|
||||
- Retry connecting with incremental back-off
|
||||
- Publishing and subscribing to topics of QoS 0, 1, and 2
|
||||
- Connections support last will message
|
||||
- Connecting via TCP and SSL
|
||||
- The fundamentals are there, but some of the API's might change in
|
||||
the near future
|
||||
- A PubSub system where one can listen to system events. For now
|
||||
connection status and ping response times can be subscribed for
|
||||
statistics and administrative purposes.
|
||||
|
||||
Most of the public facing interface should be in the `Tortoise`
|
||||
module. See the GitHub issues for work in progress "known issues in
|
||||
the design", "what needs to be done", and so forth; feel free to open
|
||||
your own issues if something is confusing or broken.
|
||||
|
||||
I would love to get some feedback and help building this thing.
|
||||
|
||||
## Example
|
||||
|
||||
A supervised connection can be started like this:
|
||||
|
||||
``` elixir
|
||||
# connect to the server and subscribe to foo/bar with QoS 0
|
||||
Tortoise.Supervisor.start_child(
|
||||
client_id: "my_client_id",
|
||||
handler: {Tortoise.Handler.Logger, []},
|
||||
server: {Tortoise.Transport.Tcp, host: 'localhost', port: 1883},
|
||||
subscriptions: [{"foo/bar", 0}])
|
||||
|
||||
# publish a message on the broker
|
||||
Tortoise.publish("my_client_id", "foo/bar", "Hello from the World of Tomorrow !", qos: 0)
|
||||
```
|
||||
|
||||
To connect to a MQTT server using SSL the `Tortoise.Transport.SSL`
|
||||
transport can be used. This requires configuration of the server's
|
||||
CA certificate, and possibly a client certificate and key. For
|
||||
example, when using the [certifi](https://hex.pm/packages/certifi)
|
||||
package as the CA trust store:
|
||||
|
||||
``` elixir
|
||||
Tortoise.Supervisor.start_child(
|
||||
client_id: "smart-spoon",
|
||||
handler: {Tortoise.Handler.Logger, []},
|
||||
server: {
|
||||
Tortoise.Transport.SSL,
|
||||
host: host, port: port,
|
||||
cacertfile: :certifi.cacertfile(),
|
||||
key: key, cert: cert
|
||||
},
|
||||
subscriptions: [{"foo/bar", 0}])
|
||||
```
|
||||
|
||||
Alternatively, for testing purposes, server certificate verification
|
||||
can be disabled by passing `verify: :verify_none` in the server
|
||||
options. In that case there is no need for CA certificates, but an
|
||||
attacker could intercept the connection without detection!
|
||||
|
||||
Look at the `connection_test.exs`-file for more examples.
|
||||
|
||||
Example Handler
|
||||
```elixir
|
||||
defmodule Tortoise.Handler.Example do
|
||||
use Tortoise.Handler
|
||||
|
||||
def init(args) do
|
||||
{:ok, args}
|
||||
end
|
||||
|
||||
def connection(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
|
||||
# a `:down`
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
# topic filter room/+/temp
|
||||
def handle_message(["room", room, "temp"], payload, state) do
|
||||
# :ok = Temperature.record(room, payload)
|
||||
{:ok, state}
|
||||
end
|
||||
def handle_message(topic, payload, state) do
|
||||
# unhandled message! You will crash if you subscribe to something
|
||||
# and you don't have a 'catch all' matcher; crashing on unexpected
|
||||
# messages could be a strategy though.
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def subscription(status, topic_filter, state) do
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def terminate(reason, state) do
|
||||
# tortoise doesn't care about what you return from terminate/2,
|
||||
# that is in alignment with other behaviours that implement a
|
||||
# terminate-callback
|
||||
:ok
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Upgrade path
|
||||
|
||||
### pre-0.9 to 0.9
|
||||
|
||||
The `subscribe/3`, `unsubscribe/3`, `subscribe_sync/3`, and
|
||||
`unsubscribe_sync/3` is no longer exposed on the `Tortoise`
|
||||
module. The functionality has been moved to the `Tortoise.Connection`
|
||||
module. The functions has the same arities and functionality, so the
|
||||
upgrade path is a simple search and replace:
|
||||
|
||||
- `"Tortoise.subscribe(" -> "Tortoise.Connection.subscribe("`
|
||||
- `"Tortoise.subscribe_sync(" -> "Tortoise.Connection.subscribe_sync("`
|
||||
- `"Tortoise.unsubscribe(" -> "Tortoise.Connection.unsubscribe("`
|
||||
- `"Tortoise.unsubscribe_sync(" -> "Tortoise.Connection.unsubscribe_sync("`
|
||||
|
||||
This change is done because the `Tortoise.Connection` module should be
|
||||
in charge of changes to the connection life-cycle.
|
||||
|
||||
## Installation
|
||||
|
||||
Tortoise can be installed by adding `tortoise` to your list of
|
||||
dependencies in `mix.exs`:
|
||||
|
||||
```elixir
|
||||
def deps do
|
||||
[
|
||||
{:tortoise, "~> 0.9"}
|
||||
]
|
||||
end
|
||||
```
|
||||
|
||||
Documentation should be available at
|
||||
[https://hexdocs.pm/tortoise](https://hexdocs.pm/tortoise).
|
||||
|
||||
## Development
|
||||
|
||||
To start developing, run the following commands:
|
||||
|
||||
```
|
||||
mix deps.get
|
||||
MIX_ENV=test mix eqc.install --mini
|
||||
mix test
|
||||
```
|
||||
|
||||
## Building documentation
|
||||
|
||||
To build the documentation run the following command in a terminal emulator:
|
||||
|
||||
``` shell
|
||||
MIX_ENV=docs mix docs
|
||||
```
|
||||
|
||||
This will build the documentation in place them in the *doc*-folder in
|
||||
the root of the project. These docs will also find their way to the
|
||||
Hexdocs website when the project is published on Hex.
|
||||
|
||||
## License
|
||||
|
||||
Copyright 2018 Martin Gausby
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,43 @@
|
||||
{<<"app">>,<<"tortoise">>}.
|
||||
{<<"build_tools">>,[<<"mix">>]}.
|
||||
{<<"description">>,<<"A MQTT client for Elixir.">>}.
|
||||
{<<"elixir">>,<<"~> 1.6">>}.
|
||||
{<<"files">>,
|
||||
[<<"lib">>,<<"lib/tortoise">>,<<"lib/tortoise/handler">>,
|
||||
<<"lib/tortoise/handler/default.ex">>,<<"lib/tortoise/handler/logger.ex">>,
|
||||
<<"lib/tortoise/decodable.ex">>,<<"lib/tortoise/handler.ex">>,
|
||||
<<"lib/tortoise/app.ex">>,<<"lib/tortoise/connection.ex">>,
|
||||
<<"lib/tortoise/connection">>,<<"lib/tortoise/connection/receiver.ex">>,
|
||||
<<"lib/tortoise/connection/controller.ex">>,
|
||||
<<"lib/tortoise/connection/inflight.ex">>,
|
||||
<<"lib/tortoise/connection/supervisor.ex">>,
|
||||
<<"lib/tortoise/connection/inflight">>,
|
||||
<<"lib/tortoise/connection/inflight/track.ex">>,
|
||||
<<"lib/tortoise/connection/backoff.ex">>,<<"lib/tortoise/package.ex">>,
|
||||
<<"lib/tortoise/pipe.ex">>,<<"lib/tortoise/package">>,
|
||||
<<"lib/tortoise/package/publish.ex">>,
|
||||
<<"lib/tortoise/package/pingresp.ex">>,
|
||||
<<"lib/tortoise/package/unsubscribe.ex">>,
|
||||
<<"lib/tortoise/package/pubrel.ex">>,<<"lib/tortoise/package/connect.ex">>,
|
||||
<<"lib/tortoise/package/pingreq.ex">>,<<"lib/tortoise/package/meta.ex">>,
|
||||
<<"lib/tortoise/package/suback.ex">>,<<"lib/tortoise/package/puback.ex">>,
|
||||
<<"lib/tortoise/package/connack.ex">>,
|
||||
<<"lib/tortoise/package/disconnect.ex">>,
|
||||
<<"lib/tortoise/package/subscribe.ex">>,
|
||||
<<"lib/tortoise/package/pubcomp.ex">>,<<"lib/tortoise/package/pubrec.ex">>,
|
||||
<<"lib/tortoise/package/unsuback.ex">>,<<"lib/tortoise/supervisor.ex">>,
|
||||
<<"lib/tortoise/transport">>,<<"lib/tortoise/transport/tcp.ex">>,
|
||||
<<"lib/tortoise/transport/ssl.ex">>,<<"lib/tortoise/events.ex">>,
|
||||
<<"lib/tortoise/encodable.ex">>,<<"lib/tortoise/transport.ex">>,
|
||||
<<"lib/tortoise/registry.ex">>,<<"lib/tortoise.ex">>,<<"mix.exs">>,
|
||||
<<"README.md">>,<<"CHANGELOG.md">>,<<"LICENSE">>]}.
|
||||
{<<"licenses">>,[<<"Apache 2.0">>]}.
|
||||
{<<"links">>,[{<<"GitHub">>,<<"https://github.com/gausby/tortoise">>}]}.
|
||||
{<<"name">>,<<"tortoise">>}.
|
||||
{<<"requirements">>,
|
||||
[[{<<"app">>,<<"gen_state_machine">>},
|
||||
{<<"name">>,<<"gen_state_machine">>},
|
||||
{<<"optional">>,false},
|
||||
{<<"repository">>,<<"hexpm">>},
|
||||
{<<"requirement">>,<<"~> 2.0 or ~> 3.0">>}]]}.
|
||||
{<<"version">>,<<"0.9.9">>}.
|
||||
@@ -0,0 +1,343 @@
|
||||
defmodule Tortoise do
|
||||
@moduledoc """
|
||||
A MQTT client for Elixir.
|
||||
|
||||
`Tortoise` provides ways of publishing messages to, and receiving
|
||||
messages from one or many MQTT brokers via TCP or SSL. The design
|
||||
philosophy of Tortoise is to hide the protocol specific details from
|
||||
the user, and expose interfaces and a connection life cycle that
|
||||
should feel natural to Elixir, while not limiting the capability of
|
||||
what one can do with the MQTT protocol.
|
||||
|
||||
First off, connection to a broker happens through a connection
|
||||
specification. This results in a process that can be supervised,
|
||||
either by the application the connection should live and die with,
|
||||
or by being supervised by the Tortoise application itself. Once the
|
||||
connection is established the Tortoise application should do its
|
||||
best to keep that connection open, by automatically sending keep
|
||||
alive messages (as the protocol specifies), and eventually attempt
|
||||
to reconnect if the connection should drop.
|
||||
|
||||
Secondly, a connection is specified with a user defined callback
|
||||
module, following the `Tortoise.Handler`-behaviour, which allow the
|
||||
user to hook into certain events happening in the life cycle of the
|
||||
connection. This way code can get executed when:
|
||||
|
||||
- The connection is established
|
||||
- The client has been disconnected from the broker
|
||||
- A topic filter subscription has been accepted (or declined)
|
||||
- A topic filter has been successfully unsubscribed
|
||||
- A message is received on one of the subscribed topic filters
|
||||
|
||||
Besides this there are hooks for the usual life-cycle events one
|
||||
would expect, such as `init/1` and `terminate/2`.
|
||||
|
||||
Thirdly, publishing is handled in such a way that the semantics of
|
||||
the levels of Quality of Service, specified by the MQTT protocol, is
|
||||
mapped to the Elixir message passing semantics. Tortoise expose an
|
||||
interface for publishing messages that hide the protocol details of
|
||||
message delivery (retrieval of acknowledge, release, complete
|
||||
messages) and instead provide `Tortoise.publish/4` which will
|
||||
deliver the message to the broker and receive a response in the
|
||||
process mailbox when a message with a QoS>0 has been handed to the
|
||||
server. This allow the user to keep track of the messages that has
|
||||
been delivered, or simply by using the `Tortoise.publish_sync/4`
|
||||
form that will block the calling process until the message has been
|
||||
safely handed to the broker. Messages with QoS1 or QoS2 are stored
|
||||
in a process until they are delivered, so once they are published
|
||||
the client should retry delivery to make sure they reach their
|
||||
destination.
|
||||
|
||||
An alternative way of posting messages is implemented in
|
||||
`Tortoise.Pipe`, which provide a data structure that among other
|
||||
things keep a reference to the connection socket. This allow for an
|
||||
efficient way of posting messages because the data can get shot
|
||||
directly onto the wire without having to copy the message between
|
||||
processes (unless the message has a QoS of 1 or 2, in which case
|
||||
they will end up in a process to ensure they will get
|
||||
delivered). The pipe will automatically renew its connection socket
|
||||
if the connection has been dropped, so ideally this message sending
|
||||
approach should be fast and efficient.
|
||||
"""
|
||||
|
||||
alias Tortoise.Package
|
||||
alias Tortoise.Connection
|
||||
alias Tortoise.Connection.Inflight
|
||||
|
||||
@typedoc """
|
||||
An identifier used to identify the client on the server.
|
||||
|
||||
Most servers accept a maximum of 23 UTF-8 encode bytes for a client
|
||||
id, and only the characters:
|
||||
|
||||
- "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
|
||||
Tortoise accept atoms as client ids but they it will be converted to
|
||||
a string before going on the wire. Be careful with atoms such as
|
||||
`Example` because they are expanded to the atom `:"Elixir.Example"`,
|
||||
it is really easy to hit the maximum byte limit. Solving this is
|
||||
easy, just add a `:` before the client id such as `:Example`.
|
||||
"""
|
||||
@type client_id() :: atom() | String.t()
|
||||
|
||||
@typedoc """
|
||||
A 16-bit number identifying a message in a message exchange.
|
||||
|
||||
Some MQTT packages are part of a message exchange and need an
|
||||
identifier so the server and client can distinct between multiple
|
||||
in-flight messages.
|
||||
|
||||
Tortoise will assign package identifier to packages that need them,
|
||||
so outside of tests (where it is beneficial to assert on the
|
||||
identifier of a package) it should be set by tortoise itself; so
|
||||
just leave it as `nil`.
|
||||
"""
|
||||
@type package_identifier() :: 0x0001..0xFFFF | nil
|
||||
|
||||
@typedoc """
|
||||
What Quality of Service (QoS) mode should be used.
|
||||
|
||||
Quality of Service is one of 0, 1, and 2 denoting the following:
|
||||
|
||||
- `0` no quality of service. The message is a fire and forget.
|
||||
|
||||
- `1` at least once delivery. The receiver will respond with an
|
||||
acknowledge message, so the sender will be certain that the
|
||||
message has reached the destination. It is possible that a message
|
||||
will be delivered twice though, as the package identifier for a
|
||||
publish will be relinquished when the message has been
|
||||
acknowledged, so a package with the same identifier will be
|
||||
treated as a new message though it might be a re-transmission.
|
||||
|
||||
- `2` exactly once delivery. The receiver will only receive the
|
||||
message once. This happens by having a more elaborate message
|
||||
exchange than the QoS=1 variant.
|
||||
|
||||
There are a difference in the semantics of assigning a QoS to a
|
||||
publish and a subscription. When assigned to a publish the message
|
||||
will get delivered to the server with the requested QoS; that is if
|
||||
it accept that level of QoS for the given topic.
|
||||
|
||||
When used in the context of a subscription it should be read as *the
|
||||
maximum QoS*. When messages are published to the subscribed topic
|
||||
the message will get on-warded with the same topic as it was
|
||||
delivered with, or downgraded to the maximum QoS of the subscription
|
||||
for the given subscribing client. That is, if the client subscribe
|
||||
with a maximum QoS=2 and a message is published to said topic with a
|
||||
QoS=1, the message will get downgraded to QoS=1 when on-warded to
|
||||
the client.
|
||||
"""
|
||||
@type qos() :: 0..2
|
||||
|
||||
@typedoc """
|
||||
A topic for a message.
|
||||
|
||||
According to the MQTT 3.1.1 specification a valid topic must be at
|
||||
least one character long. They are case sensitive and can include
|
||||
space characters.
|
||||
|
||||
MQTT topics consist of topic levels which are delimited with forward
|
||||
slashes `/`. A topic with a leading or trailing forward slash is
|
||||
allowed but they create distinct topics from the ones without;
|
||||
`/sports/tennis/results` are different from
|
||||
`sports/tennis/results`. While a topic level normally require at
|
||||
least one character the topic `/` (a single forward slash) is valid.
|
||||
|
||||
The server will drop the connection if it receive an invalid topic.
|
||||
"""
|
||||
@type topic() :: String.t()
|
||||
|
||||
@typedoc """
|
||||
A topic filter for a subscription.
|
||||
|
||||
The topic filter is different from a `topic` because it is allowed
|
||||
to contain wildcard characters:
|
||||
|
||||
- `+` is a single level wildcard which is allowed to stand on any
|
||||
position in the topic filter. For instance: `sport/+/results` will
|
||||
match `sport/tennis/results`, `sport/soccer/results`, etc.
|
||||
|
||||
- `#` is a multi-level wildcard and is only allowed to be on the
|
||||
last position of the topic filter. For instance: `sport/#` will
|
||||
match `sport/tennis/results`, `sport/tennis/announcements`, etc.
|
||||
|
||||
The server will reject any invalid topic filter and close the
|
||||
connection.
|
||||
"""
|
||||
@type topic_filter() :: String.t()
|
||||
|
||||
@typedoc """
|
||||
An optional message payload.
|
||||
|
||||
A message can optionally have a payload. The payload is a series of
|
||||
bytes and for MQTT 3.1.1 the payload has no defined structure; any
|
||||
series of bytes will do, and the client has to make sense of it.
|
||||
|
||||
The payload will be `nil` if there is no payload. This is done to
|
||||
distinct between a zero byte binary and an empty payload.
|
||||
"""
|
||||
@type payload() :: binary() | nil
|
||||
|
||||
@doc """
|
||||
Publish a message to the MQTT broker.
|
||||
|
||||
The publish function requires a `client_id` and a valid MQTT
|
||||
topic. If no `payload` is set an empty zero byte message will get
|
||||
send to the broker.
|
||||
|
||||
Optionally an options list can get passed to the publish, making it
|
||||
possible to specify if the message should be retained on the server,
|
||||
and with what quality of service the message should be published
|
||||
with.
|
||||
|
||||
* `retain` indicates, when set to `true`, that the broker should
|
||||
retain the message for the topic. Retained messages are
|
||||
delivered to clients when they subscribe to the topic. Only one
|
||||
message at a time can be retained for a given topic, so sending
|
||||
a new one will overwrite the old. `retain` defaults to `false`.
|
||||
|
||||
* `qos` set the quality of service, and integer of 0, 1, or 2. The
|
||||
`qos` defaults to `0`.
|
||||
|
||||
Publishing a message with the payload *hello* to to topic *foo/bar*
|
||||
with a *QoS1* could look like this:
|
||||
|
||||
Tortoise.publish("client_id", "foo/bar", "hello", qos: 1)
|
||||
|
||||
Notice that if you want to send a message with an empty payload with
|
||||
options you will have to set to payload to nil like this:
|
||||
|
||||
Tortoise.publish("client_id", "foo/bar", nil, retain: true)
|
||||
|
||||
## Return Values
|
||||
|
||||
The specified Quality of Service for a given publish will alter the
|
||||
behaviour of the return value. When publishing a message with a QoS0
|
||||
an `:ok` will simply get returned. This is because a QoS0 is a "fire
|
||||
and forget." There are no quality of service so no efforts are made
|
||||
to ensure that the message will reach its destination (though it very
|
||||
likely will).
|
||||
|
||||
:ok = Tortoise.publish("client_id", "foo/bar", nil, qos: 0)
|
||||
|
||||
When a message is published using either a QoS1 or QoS2, Tortoise
|
||||
will ensure that the message is delivered. A unique reference will
|
||||
get returned and eventually a message will get delivered to the
|
||||
process mailbox, containing the result of the publish when it has
|
||||
been handed over:
|
||||
|
||||
{:ok, ref} = Tortoise.publish("client_id", "foo/bar", nil, qos: 2)
|
||||
receive do
|
||||
{{Tortoise, "client_id"}, ^ref, result} ->
|
||||
IO.inspect({:result, result})
|
||||
after
|
||||
5000 ->
|
||||
{:error, :timeout}
|
||||
end
|
||||
|
||||
Be sure to implement a `handle_info/2` in `GenServer` processes that
|
||||
publish messages using Tortoise.publish/4. Notice that the returned
|
||||
message has a structure:
|
||||
|
||||
{{Tortoise, "client_id"}, ^ref, result}
|
||||
|
||||
It is possible to send to multiple clients and blanket match on
|
||||
results designated for a given client id, and the message is tagged
|
||||
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}
|
||||
when payload: binary() | nil,
|
||||
options:
|
||||
{:qos, qos()}
|
||||
| {:retain, boolean()}
|
||||
| {:identifier, package_identifier()}
|
||||
| {:timeout, non_neg_integer()}
|
||||
def publish(client_id, topic, payload \\ nil, opts \\ []) do
|
||||
qos = Keyword.get(opts, :qos, 0)
|
||||
|
||||
publish = %Package.Publish{
|
||||
topic: topic,
|
||||
qos: qos,
|
||||
payload: payload,
|
||||
retain: Keyword.get(opts, :retain, false)
|
||||
}
|
||||
|
||||
timeout = Keyword.get(opts, :timeout, :infinity)
|
||||
|
||||
with {:ok, {transport, socket}} <- Connection.connection(client_id, timeout: timeout) do
|
||||
case publish do
|
||||
%Package.Publish{qos: 0} ->
|
||||
encoded_publish = Package.encode(publish)
|
||||
apply(transport, :send, [socket, encoded_publish])
|
||||
|
||||
%Package.Publish{qos: qos} when qos in [1, 2] ->
|
||||
Inflight.track(client_id, {:outgoing, publish})
|
||||
end
|
||||
else
|
||||
{:error, :unknown_connection} ->
|
||||
{:error, :unknown_connection}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Synchronously send a message to the MQTT broker.
|
||||
|
||||
This is very similar to `Tortoise.publish/4` with the difference
|
||||
that it will block the calling process until the message has been
|
||||
handed over to the server; the configuration options are the same
|
||||
with the addition of the `timeout` option which specifies how long
|
||||
we are willing to wait for a reply. Per default the timeout is set
|
||||
to `:infinity`, it is advisable to set it to a reasonable amount in
|
||||
milliseconds as it otherwise could block forever.
|
||||
|
||||
msg = "Hello, from the World of Tomorrow !"
|
||||
case Tortoise.publish_sync("my_client_id", "foo/bar", msg, qos: 2, timeout: 200) do
|
||||
:ok ->
|
||||
:done
|
||||
|
||||
{:error, :timeout} ->
|
||||
:timeout
|
||||
end
|
||||
|
||||
Notice: It does not make sense to use `publish_sync/4` on a publish
|
||||
that has a QoS=0, because that will return instantly anyways. It is
|
||||
made possible for consistency, and it is the default QoS.
|
||||
|
||||
See the documentation for `Tortoise.publish/4` for configuration.
|
||||
"""
|
||||
@spec publish_sync(client_id(), topic(), payload, [options]) ::
|
||||
:ok | {:error, :unknown_connection}
|
||||
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)
|
||||
qos = Keyword.get(opts, :qos, 0)
|
||||
|
||||
publish = %Package.Publish{
|
||||
topic: topic,
|
||||
qos: qos,
|
||||
payload: payload,
|
||||
retain: Keyword.get(opts, :retain, false)
|
||||
}
|
||||
|
||||
with {:ok, {transport, socket}} <- Connection.connection(client_id, timeout: timeout) do
|
||||
case publish do
|
||||
%Package.Publish{qos: 0} ->
|
||||
encoded_publish = Package.encode(publish)
|
||||
apply(transport, :send, [socket, encoded_publish])
|
||||
|
||||
%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}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
defmodule Tortoise.App do
|
||||
@moduledoc false
|
||||
|
||||
use Application
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
# read configuration and start connections
|
||||
# start with client_id, and handler from config
|
||||
|
||||
children = [
|
||||
{Registry, [keys: :unique, name: Tortoise.Registry]},
|
||||
{Registry, [keys: :duplicate, name: Tortoise.Events]},
|
||||
{Tortoise.Supervisor, [strategy: :one_for_one]}
|
||||
]
|
||||
|
||||
opts = [strategy: :one_for_one, name: Tortoise]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,666 @@
|
||||
defmodule Tortoise.Connection do
|
||||
@moduledoc """
|
||||
Establish a connection to a MQTT broker.
|
||||
|
||||
Todo.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
require Logger
|
||||
|
||||
defstruct [:client_id, :connect, :server, :status, :backoff, :subscriptions, :keep_alive, :opts]
|
||||
alias __MODULE__, as: State
|
||||
|
||||
alias Tortoise.{Transport, Connection, Package, Events}
|
||||
alias Tortoise.Connection.{Inflight, Controller, Receiver, Backoff}
|
||||
alias Tortoise.Package.{Connect, Connack}
|
||||
|
||||
@doc """
|
||||
Start a connection process and link it to the current process.
|
||||
|
||||
Read the documentation on `child_spec/1` if you want... (todo!)
|
||||
|
||||
"""
|
||||
@spec start_link(options, GenServer.options()) :: GenServer.on_start()
|
||||
when option:
|
||||
{:client_id, Tortoise.client_id()}
|
||||
| {:server, {atom(), term()}}
|
||||
| {:user_name, String.t()}
|
||||
| {: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
|
||||
client_id = Keyword.fetch!(connection_opts, :client_id)
|
||||
server = connection_opts |> Keyword.fetch!(:server) |> Transport.new()
|
||||
|
||||
connect = %Package.Connect{
|
||||
client_id: client_id,
|
||||
user_name: Keyword.get(connection_opts, :user_name),
|
||||
password: Keyword.get(connection_opts, :password),
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@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)
|
||||
end
|
||||
|
||||
@spec child_spec(Keyword.t()) :: %{
|
||||
id: term(),
|
||||
start: {__MODULE__, :start_link, [Keyword.t()]},
|
||||
restart: :transient | :permanent | :temporary,
|
||||
type: :worker
|
||||
}
|
||||
def child_spec(opts) do
|
||||
%{
|
||||
id: Keyword.get(opts, :name, __MODULE__),
|
||||
start: {__MODULE__, :start_link, [opts]},
|
||||
restart: Keyword.get(opts, :restart, :transient),
|
||||
type: :worker
|
||||
}
|
||||
end
|
||||
|
||||
@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.
|
||||
"""
|
||||
@spec disconnect(Tortoise.client_id()) :: :ok
|
||||
def disconnect(client_id) do
|
||||
GenServer.call(via_name(client_id), :disconnect)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Return the list of subscribed topics.
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Subscribe to one or more topics using topic filters on `client_id`
|
||||
|
||||
The topic filter should be a 2-tuple, `{topic_filter, qos}`, where
|
||||
the `topic_filter` is a valid MQTT topic filter, and `qos` an
|
||||
integer value 0 through 2.
|
||||
|
||||
Multiple topics can be given as a list.
|
||||
|
||||
The subscribe function is asynchronous, so it will return `{:ok,
|
||||
ref}`. Eventually a response will get delivered to the process
|
||||
mailbox, tagged with the reference stored in `ref`. It will take the
|
||||
form of:
|
||||
|
||||
{{Tortoise, ^client_id}, ^ref, ^result}
|
||||
|
||||
Where the `result` can be one of `:ok`, or `{:error, reason}`.
|
||||
|
||||
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()}
|
||||
when topics: [topic],
|
||||
topic: {Tortoise.topic_filter(), Tortoise.qos()},
|
||||
options:
|
||||
{:timeout, timeout()}
|
||||
| {:identifier, Tortoise.package_identifier()}
|
||||
def subscribe(client_id, topics, opts \\ [])
|
||||
|
||||
def subscribe(client_id, [{_, n} | _] = topics, opts) when is_number(n) do
|
||||
caller = {_, ref} = {self(), make_ref()}
|
||||
{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}
|
||||
end
|
||||
|
||||
def subscribe(client_id, {_, n} = topic, opts) when is_number(n) do
|
||||
subscribe(client_id, [topic], opts)
|
||||
end
|
||||
|
||||
def subscribe(client_id, 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)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Subscribe to topics and block until the server acknowledges.
|
||||
|
||||
This is a synchronous version of the
|
||||
`Tortoise.Connection.subscribe/3`. In fact it calls into
|
||||
`Tortoise.Connection.subscribe/3` but will handle the selective
|
||||
receive loop, making it much easier to work with. Also, this
|
||||
function can be used to block a process that cannot continue before
|
||||
it has a subscription to the given topics.
|
||||
|
||||
See `Tortoise.Connection.subscribe/3` for configuration options.
|
||||
"""
|
||||
@spec subscribe_sync(Tortoise.client_id(), 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(client_id, [{_, n} | _] = topics, opts) when is_number(n) do
|
||||
timeout = Keyword.get(opts, :timeout, 5000)
|
||||
{:ok, ref} = subscribe(client_id, topics, opts)
|
||||
|
||||
receive do
|
||||
{{Tortoise, ^client_id}, ^ref, result} -> result
|
||||
after
|
||||
timeout ->
|
||||
{:error, :timeout}
|
||||
end
|
||||
end
|
||||
|
||||
def subscribe_sync(client_id, {_, n} = topic, opts) when is_number(n) do
|
||||
subscribe_sync(client_id, [topic], opts)
|
||||
end
|
||||
|
||||
def subscribe_sync(client_id, 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)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Unsubscribe from one of more topic filters. The topic filters are
|
||||
given as strings. Multiple topic filters can be given at once by
|
||||
passing in a list of strings.
|
||||
|
||||
Tortoise.Connection.unsubscribe(client_id, ["foo/bar", "quux"])
|
||||
|
||||
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()}
|
||||
when topics: [topic],
|
||||
topic: Tortoise.topic_filter(),
|
||||
options:
|
||||
{:timeout, timeout()}
|
||||
| {:identifier, Tortoise.package_identifier()}
|
||||
def unsubscribe(client_id, topics, opts \\ [])
|
||||
|
||||
def unsubscribe(client_id, [topic | _] = topics, opts) when is_binary(topic) do
|
||||
caller = {_, ref} = {self(), make_ref()}
|
||||
{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}
|
||||
end
|
||||
|
||||
def unsubscribe(client_id, topic, opts) when is_binary(topic) do
|
||||
unsubscribe(client_id, [topic], opts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Unsubscribe from topics and block until the server acknowledges.
|
||||
|
||||
This is a synchronous version of
|
||||
`Tortoise.Connection.unsubscribe/3`. It will block until the server
|
||||
has send the acknowledge message.
|
||||
|
||||
See `Tortoise.Connection.unsubscribe/3` for configuration options.
|
||||
"""
|
||||
@spec unsubscribe_sync(Tortoise.client_id(), 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(client_id, topics, opts) when is_list(topics) do
|
||||
timeout = Keyword.get(opts, :timeout, 5000)
|
||||
{:ok, ref} = unsubscribe(client_id, topics, opts)
|
||||
|
||||
receive do
|
||||
{{Tortoise, ^client_id}, ^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)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Ping the broker.
|
||||
|
||||
When the round-trip is complete a message with the time taken in
|
||||
milliseconds will be send to the process that invoked the ping
|
||||
command.
|
||||
|
||||
The connection will automatically ping the broker at the interval
|
||||
specified in the connection configuration, so there is no need to
|
||||
setup a reoccurring ping. This ping function is exposed for
|
||||
debugging purposes. If ping latency over time is desired it is
|
||||
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
|
||||
|
||||
@doc """
|
||||
Ping the server and await the ping latency reply.
|
||||
|
||||
Takes a `client_id` 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
|
||||
in milliseconds. The `timeout` defaults to `:infinity`, so it is
|
||||
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)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
Tortoise.Registry.put_meta(via_name(client_id), :connecting)
|
||||
Tortoise.Events.register(client_id, :status)
|
||||
|
||||
# eventually, switch to handle_continue
|
||||
send(self(), :connect)
|
||||
{:ok, state}
|
||||
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
|
||||
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}
|
||||
|
||||
{:error, reason} ->
|
||||
{timeout, state} = Map.get_and_update(state, :backoff, &Backoff.next/1)
|
||||
|
||||
case categorize_error(reason) do
|
||||
:connectivity ->
|
||||
Process.send_after(self(), :connect, timeout)
|
||||
{:noreply, state}
|
||||
|
||||
:other ->
|
||||
{:stop, reason, state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info(:subscribe, %State{subscriptions: subscriptions} = state) do
|
||||
client_id = state.connect.client_id
|
||||
|
||||
case Enum.empty?(subscriptions) do
|
||||
true ->
|
||||
# nothing to subscribe to, just continue
|
||||
{:noreply, state}
|
||||
|
||||
false ->
|
||||
# subscribe to the predefined topics
|
||||
case Inflight.track_sync(client_id, {:outgoing, subscriptions}, 5000) do
|
||||
{:error, :timeout} ->
|
||||
{:stop, :subscription_timeout, state}
|
||||
|
||||
result ->
|
||||
case handle_suback_result(result, state) do
|
||||
{:ok, updated_state} ->
|
||||
{:noreply, updated_state}
|
||||
|
||||
{:error, reasons} ->
|
||||
error = {:unable_to_subscribe, reasons}
|
||||
{:stop, error, state}
|
||||
end
|
||||
end
|
||||
end
|
||||
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}
|
||||
|
||||
{:error, :timeout} ->
|
||||
{:stop, :ping_timeout, state}
|
||||
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}
|
||||
end
|
||||
|
||||
# react to connection status change events
|
||||
def handle_info(
|
||||
{{Tortoise, client_id}, :status, status},
|
||||
%{client_id: client_id, status: current} = state
|
||||
) do
|
||||
case status do
|
||||
^current ->
|
||||
{:noreply, state}
|
||||
|
||||
:up ->
|
||||
{:noreply, %State{state | status: status}}
|
||||
|
||||
:down ->
|
||||
send(self(), :connect)
|
||||
{:noreply, %State{state | status: status}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:subscriptions, _from, state) do
|
||||
{:reply, state.subscriptions, state}
|
||||
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}
|
||||
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)
|
||||
|
||||
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}
|
||||
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)
|
||||
|
||||
case Inflight.track_sync(client_id, {:outgoing, unsubscribe}, timeout) do
|
||||
{:error, :timeout} = error ->
|
||||
send(caller_pid, {{Tortoise, client_id}, ref, error})
|
||||
{:noreply, state}
|
||||
|
||||
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}}
|
||||
end
|
||||
end
|
||||
|
||||
# Helpers
|
||||
defp handle_suback_result(%{:error => []} = results, %State{} = state) do
|
||||
subscriptions = Enum.into(results[:ok], state.subscriptions)
|
||||
{:ok, %State{state | subscriptions: subscriptions}}
|
||||
end
|
||||
|
||||
defp handle_suback_result(%{:error => errors}, %State{}) do
|
||||
{:error, errors}
|
||||
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}
|
||||
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}
|
||||
end
|
||||
|
||||
defp cancel_keep_alive(%State{keep_alive: nil} = state) do
|
||||
state
|
||||
end
|
||||
|
||||
defp cancel_keep_alive(%State{keep_alive: keep_alive_ref} = state) do
|
||||
_ = Process.cancel_timer(keep_alive_ref)
|
||||
%State{state | keep_alive: nil}
|
||||
end
|
||||
|
||||
# dispatch connection status if the connection status change
|
||||
defp update_connection_status(%State{status: same} = state, same) do
|
||||
state
|
||||
end
|
||||
|
||||
defp update_connection_status(%State{} = state, status) do
|
||||
:ok = Events.dispatch(state.connect.client_id, :status, status)
|
||||
%State{state | status: status}
|
||||
end
|
||||
|
||||
defp do_connect(server, %Connect{} = connect) do
|
||||
%Transport{type: transport, host: host, port: port, opts: opts} = server
|
||||
|
||||
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}
|
||||
|
||||
%Connack{status: {:refused, _reason}} = connack ->
|
||||
connack
|
||||
end
|
||||
catch
|
||||
:error, {:badmatch, _unexpected} ->
|
||||
violation = %{expected: Connect, got: packet}
|
||||
{:error, {:protocol_violation, violation}}
|
||||
end
|
||||
else
|
||||
{:error, :econnrefused} ->
|
||||
{:error, {:connection_refused, host, port}}
|
||||
|
||||
{:error, :nxdomain} ->
|
||||
{:error, {:nxdomain, host, port}}
|
||||
|
||||
{:error, {:options, {:cacertfile, []}}} ->
|
||||
{:error, :no_cacertfile_specified}
|
||||
|
||||
{:error, :closed} ->
|
||||
{:error, :server_closed_connection}
|
||||
|
||||
{:error, :timeout} ->
|
||||
{:error, :connection_timeout}
|
||||
|
||||
{:error, other} ->
|
||||
{:error, other}
|
||||
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)
|
||||
|
||||
# set clean session to false for future reconnect attempts
|
||||
connect = %Connect{connect | clean_session: false}
|
||||
{:ok, %State{state | connect: connect}}
|
||||
end
|
||||
|
||||
defp start_connection_supervisor(opts) do
|
||||
case Connection.Supervisor.start_link(opts) do
|
||||
{:ok, _pid} ->
|
||||
:ok
|
||||
|
||||
{:error, {:already_started, _pid}} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp categorize_error({:nxdomain, _host, _port}) do
|
||||
:connectivity
|
||||
end
|
||||
|
||||
defp categorize_error({:connection_refused, _host, _port}) do
|
||||
:connectivity
|
||||
end
|
||||
|
||||
defp categorize_error(:server_closed_connection) do
|
||||
:connectivity
|
||||
end
|
||||
|
||||
defp categorize_error(:connection_timeout) do
|
||||
:connectivity
|
||||
end
|
||||
|
||||
defp categorize_error(:enetunreach) do
|
||||
:connectivity
|
||||
end
|
||||
|
||||
defp categorize_error(_other) do
|
||||
:other
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
defmodule Tortoise.Connection.Backoff do
|
||||
@moduledoc false
|
||||
|
||||
defstruct min_interval: 100, max_interval: 30_000, value: nil
|
||||
alias __MODULE__, as: State
|
||||
|
||||
@doc """
|
||||
Create an opaque data structure that describe a incremental
|
||||
back-off.
|
||||
"""
|
||||
def new(opts) do
|
||||
min_interval = Keyword.get(opts, :min_interval, 100)
|
||||
max_interval = Keyword.get(opts, :max_interval, 30_000)
|
||||
|
||||
%State{min_interval: min_interval, max_interval: max_interval}
|
||||
end
|
||||
|
||||
def next(%State{value: nil} = state) do
|
||||
current = state.min_interval
|
||||
{current, %State{state | value: current}}
|
||||
end
|
||||
|
||||
def next(%State{max_interval: same, value: same} = state) do
|
||||
current = state.min_interval
|
||||
{current, %State{state | value: current}}
|
||||
end
|
||||
|
||||
def next(%State{value: value} = state) do
|
||||
current = min(value * 2, state.max_interval)
|
||||
{current, %State{state | value: current}}
|
||||
end
|
||||
|
||||
def reset(%State{} = state) do
|
||||
%State{state | value: nil}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,355 @@
|
||||
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, <<package::binary>>}, 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
|
||||
@@ -0,0 +1,378 @@
|
||||
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)
|
||||
|
||||
<<identifier::integer-size(16)>> ->
|
||||
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
|
||||
@@ -0,0 +1,248 @@
|
||||
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
|
||||
@@ -0,0 +1,197 @@
|
||||
defmodule Tortoise.Connection.Receiver do
|
||||
@moduledoc false
|
||||
|
||||
use GenStateMachine
|
||||
|
||||
alias Tortoise.Connection.Controller
|
||||
alias Tortoise.Events
|
||||
|
||||
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
|
||||
|
||||
defp via_name(client_id) do
|
||||
Tortoise.Registry.via_name(__MODULE__, client_id)
|
||||
end
|
||||
|
||||
def child_spec(opts) do
|
||||
%{
|
||||
id: __MODULE__,
|
||||
start: {__MODULE__, :start_link, [opts]},
|
||||
type: :worker,
|
||||
restart: :permanent,
|
||||
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
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(%State{} = data) do
|
||||
{:ok, :disconnected, data}
|
||||
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
|
||||
next_actions = [
|
||||
{:next_event, :internal, :activate_socket},
|
||||
{:next_event, :internal, :consume_buffer}
|
||||
]
|
||||
|
||||
new_data = %{data | buffer: <<data.buffer::binary, tcp_data::binary>>}
|
||||
{: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}}
|
||||
end
|
||||
|
||||
# activate network socket for incoming traffic
|
||||
def handle_event(:internal, :activate_socket, _state_name, %State{transport: nil}) do
|
||||
{:stop, :no_transport}
|
||||
end
|
||||
|
||||
def handle_event(:internal, :activate_socket, _state_name, data) do
|
||||
case data.transport.setopts(data.socket, active: :once) do
|
||||
:ok ->
|
||||
:keep_state_and_data
|
||||
|
||||
{:error, :einval} ->
|
||||
# @todo consider if there could be a buffer we should drain at this point
|
||||
{:next_state, :disconnected, data}
|
||||
end
|
||||
end
|
||||
|
||||
# consume buffer
|
||||
def handle_event(:internal, :consume_buffer, _state_name, %{buffer: <<>>}) do
|
||||
:keep_state_and_data
|
||||
end
|
||||
|
||||
# consuming message
|
||||
def handle_event(
|
||||
:internal,
|
||||
:consume_buffer,
|
||||
{:connected, {:receiving_variable, length}},
|
||||
%State{buffer: buffer} = data
|
||||
)
|
||||
when byte_size(buffer) >= length do
|
||||
<<package::binary-size(length), rest::binary>> = buffer
|
||||
next_state = {:connected, :receiving_fixed_header}
|
||||
|
||||
next_actions = [
|
||||
{:next_event, :internal, {:emit, package}},
|
||||
{:next_event, :internal, :consume_buffer}
|
||||
]
|
||||
|
||||
new_data = %{data | buffer: rest}
|
||||
{:next_state, next_state, new_data, next_actions}
|
||||
end
|
||||
|
||||
def handle_event(:internal, :consume_buffer, {:connected, {:receiving_variable, _}}, _data) do
|
||||
# await more bytes
|
||||
:keep_state_and_data
|
||||
end
|
||||
|
||||
# what should happen here?
|
||||
def handle_event(:internal, :consume_buffer, :disconnected, _data) do
|
||||
# we might be disconnected, but could we have data in the buffer still ?
|
||||
# perhaps we should consume that
|
||||
:keep_state_and_data
|
||||
end
|
||||
|
||||
# receiving fixed header
|
||||
def handle_event(:internal, :consume_buffer, {:connected, :receiving_fixed_header}, data) do
|
||||
case parse_fixed_header(data.buffer) do
|
||||
{:ok, length} ->
|
||||
new_state = {:connected, {:receiving_variable, length}}
|
||||
next_actions = [{:next_event, :internal, :consume_buffer}]
|
||||
{:next_state, new_state, data, next_actions}
|
||||
|
||||
:cont ->
|
||||
:keep_state_and_data
|
||||
|
||||
{:error, :invalid_header_length} ->
|
||||
{:stop, {:protocol_violation, :invalid_header_length}}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event(:internal, {:emit, package}, _, data) do
|
||||
:ok = Controller.handle_incoming(data.client_id, package)
|
||||
:keep_state_and_data
|
||||
end
|
||||
|
||||
def handle_event({:call, from}, {:handle_socket, transport, socket}, :disconnected, data) do
|
||||
new_state = {:connected, :receiving_fixed_header}
|
||||
|
||||
next_actions = [
|
||||
{:reply, from, {:ok, self()}},
|
||||
{:next_event, :internal, :activate_socket},
|
||||
{:next_event, :internal, :consume_buffer}
|
||||
]
|
||||
|
||||
# better reset the buffer
|
||||
new_data = %State{data | transport: transport, socket: socket, buffer: <<>>}
|
||||
|
||||
{:next_state, new_state, new_data, next_actions}
|
||||
end
|
||||
|
||||
defp parse_fixed_header(<<_::8, 0::1, length::7, _::binary>>) do
|
||||
{:ok, length + 2}
|
||||
end
|
||||
|
||||
# 2 bytes
|
||||
defp parse_fixed_header(<<_::8, 1::1, a::7, 0::1, b::7, _::binary>>) do
|
||||
<<length::integer-size(14)>> = <<b::7, a::7>>
|
||||
{:ok, length + 3}
|
||||
end
|
||||
|
||||
# 3 bytes
|
||||
defp parse_fixed_header(<<_::8, 1::1, a::7, 1::1, b::7, 0::1, c::7, _::binary>>) do
|
||||
<<length::integer-size(21)>> = <<c::7, b::7, a::7>>
|
||||
{:ok, length + 4}
|
||||
end
|
||||
|
||||
# 4 bytes
|
||||
defp parse_fixed_header(<<_::8, 1::1, a::7, 1::1, b::7, 1::1, c::7, 0::1, d::7, _::binary>>) do
|
||||
<<length::integer-size(28)>> = <<d::7, c::7, b::7, a::7>>
|
||||
{:ok, length + 5}
|
||||
end
|
||||
|
||||
defp parse_fixed_header(header) when byte_size(header) > 5 do
|
||||
{:error, :invalid_header_length}
|
||||
end
|
||||
|
||||
defp parse_fixed_header(_) do
|
||||
:cont
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
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
|
||||
@@ -0,0 +1,47 @@
|
||||
defprotocol Tortoise.Decodable do
|
||||
@moduledoc false
|
||||
|
||||
def decode(data)
|
||||
end
|
||||
|
||||
defimpl Tortoise.Decodable, for: BitString do
|
||||
alias Tortoise.Package.{
|
||||
Connect,
|
||||
Connack,
|
||||
Publish,
|
||||
Puback,
|
||||
Pubrec,
|
||||
Pubrel,
|
||||
Pubcomp,
|
||||
Subscribe,
|
||||
Suback,
|
||||
Unsubscribe,
|
||||
Unsuback,
|
||||
Pingreq,
|
||||
Pingresp,
|
||||
Disconnect
|
||||
}
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
defimpl Tortoise.Decodable, for: List do
|
||||
def decode(data) do
|
||||
data
|
||||
|> IO.iodata_to_binary()
|
||||
|> Tortoise.Decodable.decode()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
defprotocol Tortoise.Encodable do
|
||||
@moduledoc false
|
||||
|
||||
@spec encode(t) :: iodata()
|
||||
def encode(package)
|
||||
end
|
||||
@@ -0,0 +1,70 @@
|
||||
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
|
||||
@@ -0,0 +1,413 @@
|
||||
defmodule Tortoise.Handler do
|
||||
@moduledoc """
|
||||
User defined callback module for handling connection life cycle events.
|
||||
|
||||
`Tortoise.Handler` defines a behaviour which can be given to a
|
||||
`Tortoise.Connection`. This allow the user to implement
|
||||
functionality for events happening in the life cycle of the
|
||||
connection, the provided callbacks are:
|
||||
|
||||
- `init/1` and `terminate/2` are called when the connection is
|
||||
started and stopped. Parts that are setup in `init/1` can be
|
||||
torn down in `terminate/2`. Notice that the connection process
|
||||
is not terminated if the connection to the broker is lost;
|
||||
`Tortoise` will attempt to reconnect, and events that should
|
||||
happen when the connection goes offline should be set up using
|
||||
the `connection/3` callback.
|
||||
|
||||
- `connection/3` is called when the connection is `:up` or
|
||||
`:down`, allowing for functionality to be run when the
|
||||
connection state changes.
|
||||
|
||||
- `subscription/3` is called when a topic filter subscription
|
||||
changes status, so this callback can be used to control the
|
||||
life-cycle of a subscription, allowing us to implement custom
|
||||
behavior for when the subscription is accepted, declined, as
|
||||
well as unsubscribed.
|
||||
|
||||
- `handle_message/3` is run when the client receive a message on
|
||||
one of the subscribed topic filters.
|
||||
|
||||
Because the callback-module will run inside the connection
|
||||
controller process, which also handles the routing of protocol
|
||||
messages (such as publish acknowledge messages) it is important that
|
||||
the callbacks do not call functions that will block the process,
|
||||
especially for clients that subscribe to topics with heavy
|
||||
traffic.
|
||||
|
||||
Technically it would be possible to run the callback module in a
|
||||
different process than the controller process, but it has been
|
||||
decided to keep it on the controller as we otherwise would have to
|
||||
copy every message evaluated by the connection to another
|
||||
process. It is much better to let the end-user handle the
|
||||
dispatching to other parts of the system while we are evaluating
|
||||
what to do with the process anyways with the caveats:
|
||||
|
||||
- The callbacks should not block the controller
|
||||
|
||||
- it is not possible to call the (un)subscribe and publish
|
||||
functions from within a callback as they will block the
|
||||
controller.
|
||||
|
||||
While it is not possible to subscribe and unsubscribe in the handler
|
||||
process using the `Tortoise.subscribe/3` and
|
||||
`Tortoise.unsubscribe/3` it is possible to make changes to the
|
||||
subscription list via `:gen_statem` inspired *next_actions*.
|
||||
|
||||
## Next actions
|
||||
|
||||
In some situations one would like to subscribe or unsubscribe a
|
||||
filter topic when a certain event happens on the client. The
|
||||
functions for interacting with the MQTT broker, defined on the
|
||||
`Tortoise`-module are for the most part blocking operations, or
|
||||
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
|
||||
`connection/3` callbacks by returning a `{:ok, state, next_actions}`
|
||||
where `next_actions` is a list of commands of:
|
||||
|
||||
- `{:subscribe, topic_filter, qos: qos, timeout: 5000}` where
|
||||
`topic_filter` is a binary containing a valid MQTT topic filter,
|
||||
and `qos` is the desired quality of service (0..2). The timeout
|
||||
is the amount of time in milliseconds we are willing to wait for
|
||||
a response to the request.
|
||||
|
||||
- `{:unsubscribe, topic_filter}` where `topic_filter` is a binary
|
||||
containing the name of the subscription we want to unsubscribe
|
||||
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:
|
||||
|
||||
def handle_message(topic, _payload, state) do
|
||||
topic = Enum.join(topic, "/")
|
||||
next_actions = [{:unsubscribe, topic}]
|
||||
{:ok, 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.
|
||||
"""
|
||||
|
||||
alias Tortoise.Package
|
||||
|
||||
@typedoc """
|
||||
Data structure describing the user defined callback handler
|
||||
|
||||
The data structure describe the current state as well as its initial
|
||||
arguments and the module driving the handler. This allow Tortoise to
|
||||
restart the handler if needed be.
|
||||
"""
|
||||
@type t :: %__MODULE__{
|
||||
module: module(),
|
||||
initial_args: term(),
|
||||
state: term()
|
||||
}
|
||||
@enforce_keys [:module, :initial_args]
|
||||
defstruct module: nil, state: nil, initial_args: []
|
||||
|
||||
# Helper for building a Handler struct so we can keep it as an
|
||||
# opaque type in the system.
|
||||
@doc false
|
||||
@spec new({module(), args :: term()}) :: t
|
||||
def new({module, args}) when is_atom(module) and is_list(args) do
|
||||
%__MODULE__{module: module, initial_args: args}
|
||||
end
|
||||
|
||||
# identity
|
||||
def new(%__MODULE__{} = handler), do: handler
|
||||
|
||||
defmacro __using__(_opts) do
|
||||
quote location: :keep do
|
||||
@behaviour Tortoise.Handler
|
||||
|
||||
@impl true
|
||||
def init(state) do
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def terminate(_reason, _state) do
|
||||
:ok
|
||||
end
|
||||
|
||||
@impl true
|
||||
def connection(_status, state) do
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def subscription(_status, _topic_filter, state) do
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_message(_topic, _payload, state) do
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
defoverridable Tortoise.Handler
|
||||
end
|
||||
end
|
||||
|
||||
@typedoc """
|
||||
Action to perform before reentering the execution loop.
|
||||
|
||||
The supported next actions are:
|
||||
|
||||
- Tell the connection process to subscribe to a topic filter
|
||||
- Tell the connection process to unsubscribe from a topic filter
|
||||
|
||||
More next actions might be supported in the future.
|
||||
"""
|
||||
@type next_action() ::
|
||||
{:subscribe, Tortoise.topic_filter(), [{:qos, Tortoise.qos()} | {:timeout, timeout()}]}
|
||||
| {:unsubscribe, Tortoise.topic_filter()}
|
||||
|
||||
@doc """
|
||||
Invoked when the connection is started.
|
||||
|
||||
`args` is the argument passed in from the connection configuration.
|
||||
|
||||
Returning `{:ok, state}` will let the MQTT connection receive data
|
||||
from the MQTT broker, and the value contained in `state` will be
|
||||
used as the process state.
|
||||
"""
|
||||
@callback init(args :: term()) :: {:ok, state}
|
||||
when state: any()
|
||||
|
||||
@doc """
|
||||
Invoked when the connection status changes.
|
||||
|
||||
`status` is one of `:up` or `:down`, where up means we have an open
|
||||
connection to the MQTT broker, and down means the connection is
|
||||
temporary down. The connection process will attempt to reestablish
|
||||
the connection.
|
||||
|
||||
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 connection(status, state :: term()) ::
|
||||
{:ok, new_state}
|
||||
| {:ok, 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) ::
|
||||
{: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
|
||||
|
||||
@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
|
||||
in the function body.
|
||||
|
||||
`Payload` is a binary. MQTT 3.1.1 does not specify any format of the
|
||||
payload, so it has to be decoded and validated depending on the
|
||||
needs of the application.
|
||||
|
||||
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
|
||||
such:
|
||||
|
||||
def handle_message(["room", room, "temp"], payload, state) do
|
||||
:ok = Temperature.record(room, payload)
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
Notice; the `handle_message/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
|
||||
non-blocking calls.
|
||||
|
||||
Returning `{:ok, new_state}` will reenter the loop and 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
|
||||
reenter the loop and perform the listed actions.
|
||||
"""
|
||||
@callback handle_message(topic_levels, payload, state :: term()) ::
|
||||
{:ok, new_state}
|
||||
| {:ok, new_state, [next_action()]}
|
||||
when new_state: term(),
|
||||
topic_levels: [String.t()],
|
||||
payload: Tortoise.payload()
|
||||
|
||||
@doc """
|
||||
Invoked when the connection process is about to exit.
|
||||
|
||||
If anything is setup during the `init/1` callback it should get
|
||||
cleaned up during the `terminate/2` callback.
|
||||
"""
|
||||
@callback terminate(reason, state :: term) :: ignored
|
||||
when reason: :normal | :shutdown | {:shutdown, term()},
|
||||
ignored: term()
|
||||
|
||||
@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
|
||||
{:ok, initial_state} ->
|
||||
{:ok, %__MODULE__{handler | state: initial_state}}
|
||||
end
|
||||
end
|
||||
|
||||
def execute(handler, {:connection, status}) do
|
||||
handler.module
|
||||
|> apply(:connection, [status, handler.state])
|
||||
|> handle_result(handler)
|
||||
end
|
||||
|
||||
def execute(handler, {:publish, %Package.Publish{} = publish}) do
|
||||
topic_list = String.split(publish.topic, "/")
|
||||
|
||||
handler.module
|
||||
|> apply(:handle_message, [topic_list, publish.payload, handler.state])
|
||||
|> handle_result(handler)
|
||||
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)
|
||||
|
||||
# _, {:stop, acc} ->
|
||||
# {:stop, acc}
|
||||
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)
|
||||
end
|
||||
|
||||
def execute(handler, {:terminate, reason}) do
|
||||
_ignored = apply(handler.module, :terminate, [reason, handler.state])
|
||||
:ok
|
||||
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)
|
||||
end
|
||||
|
||||
# handle the user defined return from the callback
|
||||
defp handle_result({:ok, updated_state}, handler) do
|
||||
{:ok, %__MODULE__{handler | state: updated_state}}
|
||||
end
|
||||
|
||||
defp handle_result({:ok, updated_state, next_actions}, handler)
|
||||
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}}
|
||||
end
|
||||
end
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
defp valid_next_action?(_otherwise), do: false
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
defmodule Tortoise.Handler.Default do
|
||||
@moduledoc false
|
||||
|
||||
use Tortoise.Handler
|
||||
end
|
||||
@@ -0,0 +1,60 @@
|
||||
defmodule Tortoise.Handler.Logger do
|
||||
@moduledoc false
|
||||
|
||||
require Logger
|
||||
|
||||
use Tortoise.Handler
|
||||
|
||||
defstruct []
|
||||
alias __MODULE__, as: State
|
||||
|
||||
def init(_opts) do
|
||||
Logger.info("Initializing handler")
|
||||
{:ok, %State{}}
|
||||
end
|
||||
|
||||
def connection(:up, state) do
|
||||
Logger.info("Connection has been established")
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def connection(:down, state) do
|
||||
Logger.warn("Connection has been dropped")
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def connection(:terminating, state) do
|
||||
Logger.warn("Connection is terminating")
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def subscription(:up, topic, state) do
|
||||
Logger.info("Subscribed to #{topic}")
|
||||
{:ok, 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}
|
||||
end
|
||||
|
||||
def subscription({:error, reason}, topic, state) do
|
||||
Logger.error("Error subscribing to #{topic}; #{inspect(reason)}")
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def subscription(:down, topic, state) do
|
||||
Logger.info("Unsubscribed from #{topic}")
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def handle_message(topic, publish, state) do
|
||||
Logger.info("#{Enum.join(topic, "/")} #{inspect(publish)}")
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def terminate(reason, _state) do
|
||||
Logger.warn("Client has been terminated with reason: #{inspect(reason)}")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
defmodule Tortoise.Package do
|
||||
@moduledoc false
|
||||
|
||||
alias Tortoise.Package
|
||||
|
||||
@opaque message ::
|
||||
Package.Connect.t()
|
||||
| Package.Connack.t()
|
||||
| Package.Publish.t()
|
||||
| Package.Puback.t()
|
||||
| Package.Pubrec.t()
|
||||
| Package.Pubrel.t()
|
||||
| Package.Pubcomp.t()
|
||||
| Package.Subscribe.t()
|
||||
| Package.Suback.t()
|
||||
| Package.Unsubscribe.t()
|
||||
| Package.Unsuback.t()
|
||||
| Package.Pingreq.t()
|
||||
| Package.Pingresp.t()
|
||||
| Package.Disconnect.t()
|
||||
|
||||
defdelegate encode(data), to: Tortoise.Encodable
|
||||
defdelegate decode(data), to: Tortoise.Decodable
|
||||
|
||||
@doc false
|
||||
def length_encode(data) do
|
||||
length_prefix = <<byte_size(data)::big-integer-size(16)>>
|
||||
[length_prefix, data]
|
||||
end
|
||||
|
||||
@doc false
|
||||
def variable_length_encode(data) when is_list(data) do
|
||||
length_prefix = data |> IO.iodata_length() |> remaining_length()
|
||||
length_prefix ++ data
|
||||
end
|
||||
|
||||
@highbit 0b10000000
|
||||
defp remaining_length(n) when n < @highbit, do: [<<0::1, n::7>>]
|
||||
|
||||
defp remaining_length(n) do
|
||||
[<<1::1, rem(n, @highbit)::7>>] ++ remaining_length(div(n, @highbit))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,69 @@
|
||||
defmodule Tortoise.Package.Connack do
|
||||
@moduledoc false
|
||||
|
||||
@opcode 2
|
||||
|
||||
alias Tortoise.Package
|
||||
|
||||
@type status :: :accepted | {:refused, refusal_reasons()}
|
||||
@type refusal_reasons ::
|
||||
:unacceptable_protocol_version
|
||||
| :identifier_rejected
|
||||
| :server_unavailable
|
||||
| :bad_user_name_or_password
|
||||
| :not_authorized
|
||||
|
||||
@opaque t :: %__MODULE__{
|
||||
__META__: Package.Meta.t(),
|
||||
session_present: boolean(),
|
||||
status: status() | nil
|
||||
}
|
||||
@enforce_keys [:status]
|
||||
defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0},
|
||||
session_present: false,
|
||||
status: nil
|
||||
|
||||
@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)
|
||||
}
|
||||
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}
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Tortoise.Encodable do
|
||||
def encode(%Package.Connack{session_present: session_present, status: status} = t)
|
||||
when status != nil do
|
||||
[
|
||||
Package.Meta.encode(t.__META__),
|
||||
<<2, 0::7, flag(session_present)::1, to_return_code(status)::8>>
|
||||
]
|
||||
end
|
||||
|
||||
defp to_return_code(:accepted), do: 0x00
|
||||
|
||||
defp to_return_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
|
||||
end
|
||||
end
|
||||
|
||||
defp flag(f) when f in [0, nil, false], do: 0
|
||||
defp flag(_), do: 1
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,161 @@
|
||||
defmodule Tortoise.Package.Connect do
|
||||
@moduledoc false
|
||||
|
||||
@opcode 1
|
||||
|
||||
alias Tortoise.Package
|
||||
|
||||
@opaque t :: %__MODULE__{
|
||||
__META__: Package.Meta.t(),
|
||||
protocol: binary(),
|
||||
protocol_version: non_neg_integer(),
|
||||
user_name: binary() | nil,
|
||||
password: binary() | nil,
|
||||
clean_session: boolean(),
|
||||
keep_alive: non_neg_integer(),
|
||||
client_id: Tortoise.client_id(),
|
||||
will: Package.Publish.t() | nil
|
||||
}
|
||||
@enforce_keys [:client_id]
|
||||
defstruct __META__: %Package.Meta{opcode: @opcode},
|
||||
protocol: "MQTT",
|
||||
protocol_version: 0b00000100,
|
||||
user_name: nil,
|
||||
password: nil,
|
||||
clean_session: true,
|
||||
keep_alive: 60,
|
||||
client_id: nil,
|
||||
will: nil
|
||||
|
||||
@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)
|
||||
|
||||
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))
|
||||
|
||||
%__MODULE__{
|
||||
client_id: options[:client_id],
|
||||
user_name: options[:user_name],
|
||||
password: options[:password],
|
||||
will:
|
||||
if will == 1 do
|
||||
%Package.Publish{
|
||||
topic: options[:will_topic],
|
||||
payload: nullify(options[:will_payload]),
|
||||
qos: will_qos,
|
||||
retain: will_retain == 1
|
||||
}
|
||||
end,
|
||||
clean_session: clean_session == 1,
|
||||
keep_alive: keep_alive
|
||||
}
|
||||
end
|
||||
|
||||
defp nullify(""), do: nil
|
||||
defp nullify(payload), do: payload
|
||||
|
||||
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
|
||||
|
||||
defp decode_length_prefixed(<<>>), do: []
|
||||
|
||||
defp decode_length_prefixed(<<length::big-integer-size(16), payload::binary>>) do
|
||||
<<item::binary-size(length), rest::binary>> = payload
|
||||
[item] ++ decode_length_prefixed(rest)
|
||||
end
|
||||
|
||||
defimpl Tortoise.Encodable do
|
||||
def encode(%Package.Connect{client_id: client_id} = t)
|
||||
when is_binary(client_id) do
|
||||
[
|
||||
Package.Meta.encode(t.__META__),
|
||||
Package.variable_length_encode([
|
||||
protocol_header(t),
|
||||
connection_flags(t),
|
||||
keep_alive(t),
|
||||
payload(t)
|
||||
])
|
||||
]
|
||||
end
|
||||
|
||||
def encode(%Package.Connect{client_id: client_id} = t)
|
||||
when is_atom(client_id) do
|
||||
encode(%Package.Connect{t | client_id: Atom.to_string(client_id)})
|
||||
end
|
||||
|
||||
defp protocol_header(%{protocol: protocol, protocol_version: version}) do
|
||||
[Package.length_encode(protocol), version]
|
||||
end
|
||||
|
||||
defp connection_flags(%{will: nil} = f) do
|
||||
<<
|
||||
flag(f.user_name)::integer-size(1),
|
||||
flag(f.password)::integer-size(1),
|
||||
# will retain
|
||||
flag(0)::integer-size(1),
|
||||
# will qos
|
||||
0::integer-size(2),
|
||||
# will flag
|
||||
flag(0)::integer-size(1),
|
||||
flag(f.clean_session)::integer-size(1),
|
||||
# reserved bit
|
||||
0::1
|
||||
>>
|
||||
end
|
||||
|
||||
defp connection_flags(%{will: %Package.Publish{}} = f) do
|
||||
<<
|
||||
flag(f.user_name)::integer-size(1),
|
||||
flag(f.password)::integer-size(1),
|
||||
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),
|
||||
# reserved bit
|
||||
0::1
|
||||
>>
|
||||
end
|
||||
|
||||
defp keep_alive(f) do
|
||||
<<f.keep_alive::big-integer-size(16)>>
|
||||
end
|
||||
|
||||
defp payload(%{will: nil} = f) do
|
||||
[f.client_id, f.user_name, f.password]
|
||||
|> Enum.filter(&is_binary/1)
|
||||
|> Enum.map(&Package.length_encode/1)
|
||||
end
|
||||
|
||||
defp payload(f) do
|
||||
will_payload = encode_payload(f.will.payload)
|
||||
|
||||
[f.client_id, f.will.topic, will_payload, f.user_name, f.password]
|
||||
|> Enum.filter(&is_binary/1)
|
||||
|> Enum.map(&Package.length_encode/1)
|
||||
end
|
||||
|
||||
defp encode_payload(nil), do: ""
|
||||
defp encode_payload(payload), do: payload
|
||||
|
||||
defp flag(f) when f in [0, nil, false], do: 0
|
||||
defp flag(_), do: 1
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
defmodule Tortoise.Package.Disconnect do
|
||||
@moduledoc false
|
||||
|
||||
@opcode 14
|
||||
|
||||
alias Tortoise.Package
|
||||
|
||||
@opaque t :: %__MODULE__{
|
||||
__META__: Package.Meta.t()
|
||||
}
|
||||
defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0}
|
||||
|
||||
@spec decode(<<_::16>>) :: t
|
||||
def decode(<<@opcode::4, 0::4, 0>>), do: %__MODULE__{}
|
||||
|
||||
# Protocols ----------------------------------------------------------
|
||||
defimpl Tortoise.Encodable do
|
||||
def encode(%Package.Disconnect{} = t) do
|
||||
[Package.Meta.encode(t.__META__), 0]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
defmodule Tortoise.Package.Meta do
|
||||
@moduledoc false
|
||||
|
||||
@opaque t() :: %__MODULE__{
|
||||
opcode: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14,
|
||||
flags: non_neg_integer()
|
||||
}
|
||||
@enforce_keys [:opcode]
|
||||
defstruct opcode: 0, flags: 0
|
||||
|
||||
def encode(meta) do
|
||||
<<meta.opcode::4, meta.flags::4>>
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
defmodule Tortoise.Package.Pingreq do
|
||||
@moduledoc false
|
||||
|
||||
@opcode 12
|
||||
|
||||
alias Tortoise.Package
|
||||
|
||||
@opaque t :: %__MODULE__{
|
||||
__META__: Package.Meta.t()
|
||||
}
|
||||
defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0}
|
||||
|
||||
@spec decode(<<_::16>>) :: t
|
||||
def decode(<<@opcode::4, 0::4, 0>>) do
|
||||
%__MODULE__{}
|
||||
end
|
||||
|
||||
# Protocols ----------------------------------------------------------
|
||||
defimpl Tortoise.Encodable do
|
||||
def encode(%Package.Pingreq{} = t) do
|
||||
[Package.Meta.encode(t.__META__), 0]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
defmodule Tortoise.Package.Pingresp do
|
||||
@moduledoc false
|
||||
|
||||
@opcode 13
|
||||
|
||||
alias Tortoise.Package
|
||||
|
||||
@opaque t :: %__MODULE__{
|
||||
__META__: Package.Meta.t()
|
||||
}
|
||||
defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0}
|
||||
|
||||
@spec decode(<<_::16>>) :: t
|
||||
def decode(<<@opcode::4, 0::4, 0>>) do
|
||||
%__MODULE__{}
|
||||
end
|
||||
|
||||
# Protocols ----------------------------------------------------------
|
||||
defimpl Tortoise.Encodable do
|
||||
def encode(%Package.Pingresp{} = t) do
|
||||
[Package.Meta.encode(t.__META__), 0]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
defmodule Tortoise.Package.Puback do
|
||||
@moduledoc false
|
||||
|
||||
@opcode 4
|
||||
|
||||
alias Tortoise.Package
|
||||
|
||||
@opaque t :: %__MODULE__{
|
||||
__META__: Package.Meta.t(),
|
||||
identifier: Tortoise.package_identifier()
|
||||
}
|
||||
@enforce_keys [:identifier]
|
||||
defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0b0000},
|
||||
identifier: nil
|
||||
|
||||
@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}
|
||||
end
|
||||
|
||||
# Protocols ----------------------------------------------------------
|
||||
defimpl Tortoise.Encodable do
|
||||
def encode(%Package.Puback{identifier: identifier} = t)
|
||||
when identifier in 0x0001..0xFFFF do
|
||||
[Package.Meta.encode(t.__META__), <<2, identifier::big-integer-size(16)>>]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
defmodule Tortoise.Package.Pubcomp do
|
||||
@moduledoc false
|
||||
|
||||
@opcode 7
|
||||
|
||||
alias Tortoise.Package
|
||||
|
||||
@opaque t :: %__MODULE__{
|
||||
__META__: Package.Meta.t(),
|
||||
identifier: Tortoise.package_identifier()
|
||||
}
|
||||
defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0},
|
||||
identifier: nil
|
||||
|
||||
@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}
|
||||
end
|
||||
|
||||
# Protocols ----------------------------------------------------------
|
||||
defimpl Tortoise.Encodable do
|
||||
def encode(%Package.Pubcomp{identifier: identifier} = t)
|
||||
when identifier in 0x0001..0xFFFF do
|
||||
[Package.Meta.encode(t.__META__), <<2, t.identifier::big-integer-size(16)>>]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,114 @@
|
||||
defmodule Tortoise.Package.Publish do
|
||||
@moduledoc false
|
||||
|
||||
@opcode 3
|
||||
|
||||
alias Tortoise.Package
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
__META__: Package.Meta.t(),
|
||||
topic: Tortoise.topic() | nil,
|
||||
qos: Tortoise.qos(),
|
||||
payload: Tortoise.payload(),
|
||||
identifier: Tortoise.package_identifier(),
|
||||
dup: boolean(),
|
||||
retain: boolean()
|
||||
}
|
||||
defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0},
|
||||
identifier: nil,
|
||||
topic: nil,
|
||||
payload: nil,
|
||||
qos: 0,
|
||||
dup: false,
|
||||
retain: false
|
||||
|
||||
@spec decode(binary()) :: t
|
||||
def decode(<<@opcode::4, 0::1, 0::2, retain::1, length_prefixed_payload::binary>>) do
|
||||
payload = drop_length_prefix(length_prefixed_payload)
|
||||
{topic, payload} = decode_message(payload)
|
||||
|
||||
%__MODULE__{
|
||||
qos: 0,
|
||||
identifier: nil,
|
||||
dup: false,
|
||||
retain: retain == 1,
|
||||
topic: topic,
|
||||
payload: payload
|
||||
}
|
||||
end
|
||||
|
||||
def decode(
|
||||
<<@opcode::4, dup::1, qos::integer-size(2), retain::1, length_prefixed_payload::binary>>
|
||||
) do
|
||||
payload = drop_length_prefix(length_prefixed_payload)
|
||||
{topic, identifier, payload} = decode_message_with_id(payload)
|
||||
|
||||
%__MODULE__{
|
||||
qos: qos,
|
||||
identifier: identifier,
|
||||
dup: dup == 1,
|
||||
retain: retain == 1,
|
||||
topic: topic,
|
||||
payload: payload
|
||||
}
|
||||
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
|
||||
|
||||
defp decode_message(<<topic_length::big-integer-size(16), msg::binary>>) do
|
||||
<<topic::binary-size(topic_length), payload::binary>> = msg
|
||||
{topic, nullify(payload)}
|
||||
end
|
||||
|
||||
defp decode_message_with_id(<<topic_length::big-integer-size(16), msg::binary>>) do
|
||||
<<topic::binary-size(topic_length), identifier::big-integer-size(16), payload::binary>> = msg
|
||||
{topic, identifier, nullify(payload)}
|
||||
end
|
||||
|
||||
defp nullify(""), do: nil
|
||||
defp nullify(payload), do: payload
|
||||
|
||||
# Protocols ----------------------------------------------------------
|
||||
defimpl Tortoise.Encodable do
|
||||
def encode(%Package.Publish{identifier: nil, qos: 0} = t) do
|
||||
[
|
||||
Package.Meta.encode(%{t.__META__ | flags: encode_flags(t)}),
|
||||
Package.variable_length_encode([
|
||||
Package.length_encode(t.topic),
|
||||
encode_payload(t)
|
||||
])
|
||||
]
|
||||
end
|
||||
|
||||
def encode(%Package.Publish{identifier: identifier, qos: qos} = t)
|
||||
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),
|
||||
<<identifier::big-integer-size(16)>>,
|
||||
encode_payload(t)
|
||||
])
|
||||
]
|
||||
end
|
||||
|
||||
defp encode_flags(%{dup: dup, qos: qos, retain: retain}) do
|
||||
<<flags::4>> = <<flag(dup)::1, qos::integer-size(2), flag(retain)::1>>
|
||||
flags
|
||||
end
|
||||
|
||||
defp encode_payload(%{payload: nil}), do: ""
|
||||
|
||||
defp encode_payload(%{payload: payload}), do: payload
|
||||
|
||||
defp flag(f) when f in [0, nil, false], do: 0
|
||||
defp flag(_), do: 1
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
defmodule Tortoise.Package.Pubrec do
|
||||
@moduledoc false
|
||||
|
||||
@opcode 5
|
||||
|
||||
alias Tortoise.Package
|
||||
|
||||
@opaque t :: %__MODULE__{
|
||||
__META__: Package.Meta.t(),
|
||||
identifier: Tortoise.package_identifier()
|
||||
}
|
||||
defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0b000},
|
||||
identifier: nil
|
||||
|
||||
@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}
|
||||
end
|
||||
|
||||
# Protocols ----------------------------------------------------------
|
||||
defimpl Tortoise.Encodable do
|
||||
def encode(%Package.Pubrec{identifier: identifier} = t)
|
||||
when identifier in 0x0001..0xFFFF do
|
||||
[Package.Meta.encode(t.__META__), <<2, t.identifier::big-integer-size(16)>>]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
defmodule Tortoise.Package.Pubrel do
|
||||
@moduledoc false
|
||||
|
||||
@opcode 6
|
||||
|
||||
alias Tortoise.Package
|
||||
|
||||
@opaque t :: %__MODULE__{
|
||||
__META__: Package.Meta.t(),
|
||||
identifier: Tortoise.package_identifier()
|
||||
}
|
||||
@enforce_keys [:identifier]
|
||||
defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0b0010},
|
||||
identifier: nil
|
||||
|
||||
@spec decode(<<_::32>>) :: t
|
||||
def decode(<<@opcode::4, 2::4, 2, identifier::big-integer-size(16)>>)
|
||||
when identifier in 0x0001..0xFFFF do
|
||||
%__MODULE__{identifier: identifier}
|
||||
end
|
||||
|
||||
# Protocols ----------------------------------------------------------
|
||||
defimpl Tortoise.Encodable do
|
||||
def encode(%Package.Pubrel{identifier: identifier} = t)
|
||||
when identifier in 0x0001..0xFFFF do
|
||||
[Package.Meta.encode(t.__META__), <<2, t.identifier::big-integer-size(16)>>]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,68 @@
|
||||
defmodule Tortoise.Package.Suback do
|
||||
@moduledoc false
|
||||
|
||||
@opcode 9
|
||||
|
||||
alias Tortoise.Package
|
||||
|
||||
@type qos :: 0 | 1 | 2
|
||||
@type ack_result :: {:ok, qos} | {:error, :access_denied}
|
||||
|
||||
@opaque t :: %__MODULE__{
|
||||
__META__: Package.Meta.t(),
|
||||
identifier: Tortoise.package_identifier(),
|
||||
acks: [ack_result]
|
||||
}
|
||||
@enforce_keys [:identifier]
|
||||
defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0},
|
||||
identifier: nil,
|
||||
acks: []
|
||||
|
||||
@spec decode(binary()) :: t
|
||||
def decode(<<@opcode::4, 0::4, payload::binary>>) do
|
||||
with payload <- drop_length(payload),
|
||||
<<identifier::big-integer-size(16), acks::binary>> <- payload do
|
||||
case return_codes_to_list(acks) do
|
||||
[] ->
|
||||
{:error, {:protocol_violation, :empty_subscription_ack}}
|
||||
|
||||
sub_acks ->
|
||||
%__MODULE__{identifier: identifier, acks: sub_acks}
|
||||
end
|
||||
end
|
||||
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
|
||||
|
||||
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(<<ack::integer, acks::binary>>) when ack in 0x00..0x02,
|
||||
do: [{:ok, ack}] ++ return_codes_to_list(acks)
|
||||
|
||||
# Protocols ----------------------------------------------------------
|
||||
defimpl Tortoise.Encodable do
|
||||
def encode(%Package.Suback{identifier: identifier} = t)
|
||||
when identifier in 0x0001..0xFFFF do
|
||||
[
|
||||
Package.Meta.encode(t.__META__),
|
||||
Package.variable_length_encode([
|
||||
<<identifier::big-integer-size(16)>>,
|
||||
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
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,110 @@
|
||||
defmodule Tortoise.Package.Subscribe do
|
||||
@moduledoc false
|
||||
|
||||
@opcode 8
|
||||
|
||||
alias Tortoise.Package
|
||||
|
||||
@type qos :: 0 | 1 | 2
|
||||
@type topic :: {binary(), qos}
|
||||
@type topics :: [topic]
|
||||
|
||||
@opaque t :: %__MODULE__{
|
||||
__META__: Package.Meta.t(),
|
||||
identifier: Tortoise.package_identifier(),
|
||||
topics: topics()
|
||||
}
|
||||
defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0b0010},
|
||||
identifier: nil,
|
||||
topics: []
|
||||
|
||||
@spec decode(binary()) :: t
|
||||
def decode(<<@opcode::4, 0b0010::4, length_prefixed_payload::binary>>) do
|
||||
payload = drop_length(length_prefixed_payload)
|
||||
<<identifier::big-integer-size(16), topics::binary>> = payload
|
||||
topic_list = decode_topics(topics)
|
||||
%__MODULE__{identifier: identifier, topics: topic_list}
|
||||
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
|
||||
|
||||
defp decode_topics(<<>>), do: []
|
||||
|
||||
defp decode_topics(<<length::big-integer-size(16), rest::binary>>) do
|
||||
<<topic::binary-size(length), return_code::integer-size(8), rest::binary>> = rest
|
||||
[{topic, return_code}] ++ decode_topics(rest)
|
||||
end
|
||||
|
||||
# PROTOCOLS ==========================================================
|
||||
defimpl Tortoise.Encodable do
|
||||
def encode(
|
||||
%Package.Subscribe{
|
||||
identifier: identifier,
|
||||
# a valid subscribe package has at least one topic/qos pair
|
||||
topics: [{<<_topic_filter::binary>>, qos} | _]
|
||||
} = t
|
||||
)
|
||||
when identifier in 0x0001..0xFFFF and qos in 0..2 do
|
||||
[
|
||||
Package.Meta.encode(t.__META__),
|
||||
Package.variable_length_encode([
|
||||
<<identifier::big-integer-size(16)>>,
|
||||
encode_topics(t.topics)
|
||||
])
|
||||
]
|
||||
end
|
||||
|
||||
defp encode_topics(topics) do
|
||||
Enum.map(topics, fn {topic, qos} ->
|
||||
[Package.length_encode(topic), <<0::6, qos::2>>]
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Enumerable do
|
||||
def reduce(%Package.Subscribe{topics: topics}, acc, fun) do
|
||||
Enumerable.List.reduce(topics, acc, fun)
|
||||
end
|
||||
|
||||
def member?(%Package.Subscribe{topics: topics}, value) do
|
||||
{:ok, Enum.member?(topics, value)}
|
||||
end
|
||||
|
||||
def count(%Package.Subscribe{topics: topics}) do
|
||||
{:ok, Enum.count(topics)}
|
||||
end
|
||||
|
||||
def slice(_) do
|
||||
# todo
|
||||
{:error, __MODULE__}
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Collectable do
|
||||
def into(%Package.Subscribe{topics: topics} = source) do
|
||||
{Enum.into(topics, %{}),
|
||||
fn
|
||||
acc, {:cont, {<<topic::binary>>, 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))
|
||||
|
||||
acc, {:cont, <<topic::binary>>} ->
|
||||
Map.put_new(acc, topic, 0)
|
||||
|
||||
acc, :done ->
|
||||
%{source | topics: Map.to_list(acc)}
|
||||
|
||||
_, :halt ->
|
||||
:ok
|
||||
end}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
defmodule Tortoise.Package.Unsuback do
|
||||
@moduledoc false
|
||||
|
||||
@opcode 11
|
||||
|
||||
alias Tortoise.Package
|
||||
|
||||
@opaque t :: %__MODULE__{
|
||||
__META__: Package.Meta.t(),
|
||||
identifier: Tortoise.package_identifier()
|
||||
}
|
||||
@enforce_keys [:identifier]
|
||||
defstruct __META__: %Package.Meta{opcode: @opcode, flags: 0b0000},
|
||||
identifier: nil
|
||||
|
||||
@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}
|
||||
end
|
||||
|
||||
# Protocols ----------------------------------------------------------
|
||||
defimpl Tortoise.Encodable do
|
||||
def encode(%Package.Unsuback{identifier: identifier} = t)
|
||||
when identifier in 0x0001..0xFFFF do
|
||||
[Package.Meta.encode(t.__META__), <<2, identifier::big-integer-size(16)>>]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,62 @@
|
||||
defmodule Tortoise.Package.Unsubscribe do
|
||||
@moduledoc false
|
||||
|
||||
@opcode 10
|
||||
|
||||
alias Tortoise.Package
|
||||
|
||||
@type topic :: binary()
|
||||
|
||||
@opaque t :: %__MODULE__{
|
||||
__META__: Package.Meta.t(),
|
||||
identifier: Tortoise.package_identifier(),
|
||||
topics: [topic]
|
||||
}
|
||||
defstruct __META__: %Package.Meta{opcode: @opcode, flags: 2},
|
||||
topics: [],
|
||||
identifier: nil
|
||||
|
||||
@spec decode(binary()) :: t
|
||||
def decode(<<@opcode::4, 0b0010::4, payload::binary>>) do
|
||||
with payload <- drop_length(payload),
|
||||
<<identifier::big-integer-size(16), topics::binary>> <- payload,
|
||||
topic_list <- decode_topics(topics),
|
||||
do: %__MODULE__{identifier: identifier, topics: topic_list}
|
||||
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
|
||||
|
||||
defp decode_topics(<<>>), do: []
|
||||
|
||||
defp decode_topics(<<length::big-integer-size(16), rest::binary>>) do
|
||||
<<topic::binary-size(length), rest::binary>> = rest
|
||||
[topic] ++ decode_topics(rest)
|
||||
end
|
||||
|
||||
# Protocols ----------------------------------------------------------
|
||||
defimpl Tortoise.Encodable do
|
||||
def encode(
|
||||
%Package.Unsubscribe{
|
||||
identifier: identifier,
|
||||
# a valid unsubscribe package has at least one topic filter
|
||||
topics: [_topic_filter | _]
|
||||
} = t
|
||||
)
|
||||
when identifier in 0x0001..0xFFFF do
|
||||
[
|
||||
Package.Meta.encode(t.__META__),
|
||||
Package.variable_length_encode([
|
||||
<<identifier::big-integer-size(16)>>,
|
||||
Enum.map(t.topics, &Package.length_encode/1)
|
||||
])
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,176 @@
|
||||
defmodule Tortoise.Pipe do
|
||||
@moduledoc """
|
||||
Experimental. This feature is under development.
|
||||
|
||||
The transmitter "pipe", for lack of a better word, is an opaque data
|
||||
type that can be given to a process. It contains amongst other
|
||||
things a socket.
|
||||
|
||||
A process can obtain a transmitter pipe by issuing a `pipe =
|
||||
Tortoise.Pipe.new(client_id)` request, which will result in a pipe
|
||||
in passive mode, meaning it will hold a socket it can publish
|
||||
messages into, but might fail, in which case it will attempt to get
|
||||
another socket from the transmitter. This all happens behind the
|
||||
scenes, it is important though that the returned pipe is used in
|
||||
future pipe requests, so publishing on a pipe should look like this:
|
||||
|
||||
pipe = Tortoise.Pipe.publish(pipe, "foo/bar", "bonjour !")
|
||||
|
||||
This is all experimental, and efforts to document this better will
|
||||
be made when the design and implementation has stabilized.
|
||||
"""
|
||||
|
||||
alias Tortoise.{Package, Pipe}
|
||||
alias Tortoise.Connection.Inflight
|
||||
|
||||
@opaque t :: %__MODULE__{
|
||||
client_id: binary(),
|
||||
socket: port(),
|
||||
transport: atom(),
|
||||
active: boolean(),
|
||||
failure: :crash | :drop,
|
||||
timeout: non_neg_integer() | :infinity,
|
||||
pending: [reference()]
|
||||
}
|
||||
@enforce_keys [:client_id]
|
||||
defstruct([
|
||||
:client_id,
|
||||
socket: nil,
|
||||
transport: Tortoise.Transport.Tcp,
|
||||
active: false,
|
||||
failure: :crash,
|
||||
timeout: :infinity,
|
||||
pending: []
|
||||
])
|
||||
|
||||
@doc """
|
||||
Create a new publisher pipe.
|
||||
"""
|
||||
def new(client_id, opts \\ []) do
|
||||
active = Keyword.get(opts, :active, false)
|
||||
timeout = Keyword.get(opts, :timeout, 5000)
|
||||
|
||||
opts = [timeout: timeout, active: active]
|
||||
|
||||
case Tortoise.Connection.connection(client_id, opts) do
|
||||
{:ok, {transport, socket}} ->
|
||||
%Pipe{client_id: client_id, transport: transport, socket: socket, active: active}
|
||||
|
||||
{:error, :unknown_connection} ->
|
||||
{:error, :unknown_connection}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Publish a message using a pipe.
|
||||
"""
|
||||
def publish(%Pipe{} = pipe, topic, payload \\ nil, opts \\ []) do
|
||||
publish = %Package.Publish{
|
||||
topic: topic,
|
||||
payload: payload,
|
||||
qos: Keyword.get(opts, :qos, 0),
|
||||
retain: Keyword.get(opts, :retain, false)
|
||||
}
|
||||
|
||||
with %Pipe{} = pipe <- do_publish(pipe, publish) do
|
||||
pipe
|
||||
else
|
||||
{:error, :timeout} ->
|
||||
# run pipe error spec
|
||||
{:error, :timeout}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_publish(%Pipe{} = pipe, %Package.Publish{qos: 0} = publish) do
|
||||
encoded_publish = Package.encode(publish)
|
||||
|
||||
case pipe.transport.send(pipe.socket, encoded_publish) do
|
||||
:ok ->
|
||||
pipe
|
||||
|
||||
{:error, :closed} ->
|
||||
case refresh(pipe) do
|
||||
%Pipe{} = pipe ->
|
||||
do_publish(pipe, publish)
|
||||
|
||||
{:error, :timeout} ->
|
||||
{:error, :timeout}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
defp refresh(%Pipe{active: true, client_id: client_id} = pipe) do
|
||||
receive do
|
||||
{{Tortoise, ^client_id}, :connection, {transport, socket}} ->
|
||||
%Pipe{pipe | transport: transport, socket: socket}
|
||||
after
|
||||
pipe.timeout ->
|
||||
{:error, :timeout}
|
||||
end
|
||||
end
|
||||
|
||||
defp refresh(%Pipe{active: false} = pipe) do
|
||||
opts = [timeout: pipe.timeout, active: false]
|
||||
|
||||
case Tortoise.Connection.connection(pipe.client_id, opts) do
|
||||
{:ok, {transport, socket}} ->
|
||||
%Pipe{pipe | transport: transport, socket: socket}
|
||||
|
||||
{:error, :timeout} ->
|
||||
{:error, :timeout}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Await for acknowledge messages for the currently pending messages.
|
||||
|
||||
Note that this enters a selective receive loop, so the await needs
|
||||
to happen before the process reaches its mailbox. It can be used in
|
||||
situations where we want to send a couple of messages and continue
|
||||
when the server has received them; This only works for messages with
|
||||
a Quality of Service above 0.
|
||||
"""
|
||||
def await(pipe, timeout \\ 5000)
|
||||
|
||||
def await(%Pipe{pending: []} = pipe, _timeout) do
|
||||
{:ok, pipe}
|
||||
end
|
||||
|
||||
def await(%Pipe{client_id: client_id, pending: [ref | rest]} = pipe, timeout) do
|
||||
receive do
|
||||
{{Tortoise, ^client_id}, ^ref, :ok} ->
|
||||
await(%Pipe{pipe | pending: rest})
|
||||
after
|
||||
timeout ->
|
||||
{:error, :timeout}
|
||||
end
|
||||
end
|
||||
|
||||
# protocols
|
||||
# defimpl Collectable do
|
||||
# def into(pipe) do
|
||||
# collector_fun = fn
|
||||
# acc, {:cont, %Package.Publish{qos: 0} = elem} ->
|
||||
# [Package.encode(elem) | acc]
|
||||
|
||||
# acc, :done ->
|
||||
# Transmitter.publish(pipe, Enum.reverse(acc))
|
||||
# acc
|
||||
|
||||
# _acc, :halt ->
|
||||
# :ok
|
||||
# end
|
||||
|
||||
# {[], collector_fun}
|
||||
# end
|
||||
# end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
defmodule Tortoise.Registry do
|
||||
@moduledoc false
|
||||
|
||||
@type via :: {:via, Registry, {__MODULE__, {module(), Tortoise.client_id()}}}
|
||||
@type key :: atom() | tuple()
|
||||
|
||||
@spec via_name(module(), Tortoise.client_id()) :: via() | pid()
|
||||
def via_name(_module, pid) when is_pid(pid), do: pid
|
||||
|
||||
def via_name(module, client_id) do
|
||||
{:via, Registry, reg_name(module, client_id)}
|
||||
end
|
||||
|
||||
@spec reg_name(module(), Tortoise.client_id()) :: {__MODULE__, {module(), Tortoise.client_id()}}
|
||||
def reg_name(module, client_id) do
|
||||
{__MODULE__, {module, client_id}}
|
||||
end
|
||||
|
||||
@spec meta(key :: key()) :: {:ok, term()} | :error
|
||||
def meta(key) do
|
||||
Registry.meta(__MODULE__, key)
|
||||
end
|
||||
|
||||
@spec put_meta(key :: key(), value :: term()) :: :ok
|
||||
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
|
||||
@@ -0,0 +1,65 @@
|
||||
defmodule Tortoise.Supervisor do
|
||||
@moduledoc """
|
||||
A dynamic supervisor that can hold `Tortoise.Connection` processes
|
||||
|
||||
A `Tortoise.Supervisor`, registered under the name
|
||||
`Tortoise.Supervisor`, is started as part of the `Tortoise.App`,
|
||||
which will get started automatically when `Tortoise` is included as
|
||||
a `Mix` dependency. Spawning a connection on the
|
||||
`Tortoise.Supervisor` can be done as follows:
|
||||
|
||||
{:ok, pid} =
|
||||
Tortoise.Supervisor.start_child(
|
||||
client_id: T1000,
|
||||
handler: {Tortoise.Handler.Logger, []},
|
||||
server: {Tortoise.Transport.Tcp, host: 'localhost', port: 1883},
|
||||
subscriptions: [{"foo/bar", 0}]
|
||||
)
|
||||
|
||||
While this is an easy way to get started one should consider the
|
||||
supervision strategy of the application using the
|
||||
`Tortoise.Connection`s. Often one would like to close the connection
|
||||
with the application using the connection, in which case one could
|
||||
consider starting the `Tortoise.Connection` directly in the
|
||||
supervisor of the application using the connection, or start a
|
||||
`Tortoise.Supervisor` as part of ones application if it require more
|
||||
than one connection.
|
||||
|
||||
See the *Connection Supervision* article in the project
|
||||
documentation for more information on connection supervision.
|
||||
"""
|
||||
|
||||
use DynamicSupervisor
|
||||
|
||||
@doc """
|
||||
Start a dynamic supervisor that can hold connection processes.
|
||||
|
||||
The `:name` option can also be given in order to register a supervisor
|
||||
name, the supported values are described in the "Name registration"
|
||||
section in the `GenServer` module docs.
|
||||
"""
|
||||
def start_link(opts) do
|
||||
DynamicSupervisor.start_link(__MODULE__, opts, name: opts[:name])
|
||||
end
|
||||
|
||||
def child_spec(opts) do
|
||||
opts = Keyword.put_new(opts, :name, __MODULE__)
|
||||
DynamicSupervisor.child_spec(opts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Start a connection as a child of the `Tortoise.Supervisor`.
|
||||
|
||||
`supervisor` is the name of the supervisor the child should be
|
||||
started on, and it defaults to `Tortoise.Supervisor`.
|
||||
"""
|
||||
def start_child(supervisor \\ __MODULE__, opts) do
|
||||
spec = {Tortoise.Connection, opts}
|
||||
DynamicSupervisor.start_child(supervisor, spec)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
DynamicSupervisor.init(strategy: :one_for_one)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
defmodule Tortoise.Transport do
|
||||
@moduledoc """
|
||||
Abstraction for working with network connections; this is done to
|
||||
normalize the `:ssl` and `:gen_tcp` modules, so they get a similar
|
||||
interface.
|
||||
|
||||
This work has been heavily inspired by the Ranch project by
|
||||
NineNines.
|
||||
"""
|
||||
|
||||
@opaque t :: %__MODULE__{
|
||||
type: atom(),
|
||||
host: binary(),
|
||||
port: non_neg_integer(),
|
||||
opts: [term()]
|
||||
}
|
||||
|
||||
@enforce_keys [:type, :host, :port]
|
||||
defstruct type: nil, host: nil, port: nil, opts: []
|
||||
|
||||
@doc """
|
||||
Create a new Transport specification used by the Connection process
|
||||
to log on to the MQTT server. This allow us to filter the options
|
||||
passed to the connection type, and guide the user to connect to the
|
||||
individual transport type.
|
||||
"""
|
||||
@spec new({atom(), [term()]}) :: t()
|
||||
def new({transport, opts}) do
|
||||
%Tortoise.Transport{type: ^transport} = transport.new(opts)
|
||||
end
|
||||
|
||||
@type socket() :: any()
|
||||
@type opts() :: any()
|
||||
@type stats() :: any()
|
||||
|
||||
@callback new(opts()) :: Tortoise.Transport.t()
|
||||
|
||||
@callback listen(opts()) :: {:ok, socket()} | {:error, atom()}
|
||||
|
||||
@callback accept(socket(), timeout()) :: {:ok, socket()} | {:error, :closed | :timeout | atom()}
|
||||
|
||||
@callback accept_ack(socket(), timeout()) :: :ok
|
||||
|
||||
@callback connect(charlist(), :inet.port_number(), opts(), timeout()) ::
|
||||
{:ok, socket()} | {:error, atom()}
|
||||
|
||||
@callback recv(socket(), non_neg_integer(), timeout()) ::
|
||||
{:ok, any()} | {:error, :closed | :timeout | atom()}
|
||||
|
||||
@callback send(socket(), iodata()) :: :ok | {:error, atom()}
|
||||
|
||||
@callback setopts(socket(), opts()) :: :ok | {:error, atom()}
|
||||
|
||||
@callback getopts(socket(), [atom()]) :: {:ok, opts()} | {:error, atom()}
|
||||
|
||||
@callback getstat(socket()) :: {:ok, stats()} | {:error, atom()}
|
||||
|
||||
@callback getstat(socket(), [atom()]) :: {:ok, stats()} | {:error, atom()}
|
||||
|
||||
@callback controlling_process(socket(), pid()) :: :ok | {:error, :closed | :now_owner | atom()}
|
||||
|
||||
@callback peername(socket()) ::
|
||||
{:ok, {:inet.ip_address(), :inet.port_number()}} | {:error, atom()}
|
||||
|
||||
@callback sockname(socket()) ::
|
||||
{:ok, {:inet.ip_address(), :inet.port_number()}} | {:error, atom()}
|
||||
|
||||
@callback shutdown(socket(), :read | :write | :read_write) :: :ok | {:error, atom()}
|
||||
|
||||
@callback close(socket()) :: :ok
|
||||
end
|
||||
@@ -0,0 +1,130 @@
|
||||
defmodule Tortoise.Transport.SSL do
|
||||
@moduledoc false
|
||||
|
||||
@behaviour Tortoise.Transport
|
||||
|
||||
alias Tortoise.Transport
|
||||
|
||||
@default_opts [verify: :verify_peer]
|
||||
|
||||
@impl true
|
||||
def new(opts) do
|
||||
{host, opts} = Keyword.pop(opts, :host)
|
||||
{port, opts} = Keyword.pop(opts, :port)
|
||||
{list_opts, opts} = Keyword.pop(opts, :opts, [])
|
||||
host = coerce_host(host)
|
||||
opts = Keyword.merge(@default_opts, opts)
|
||||
opts = [:binary, {:packet, :raw}, {:active, false} | opts] ++ list_opts
|
||||
%Transport{type: __MODULE__, host: host, port: port, opts: opts}
|
||||
end
|
||||
|
||||
defp coerce_host(host) when is_binary(host) do
|
||||
String.to_charlist(host)
|
||||
end
|
||||
|
||||
defp coerce_host(otherwise) do
|
||||
otherwise
|
||||
end
|
||||
|
||||
@impl true
|
||||
def connect(host, port, opts, timeout) do
|
||||
# [:binary, active: false, packet: :raw]
|
||||
:ssl.connect(host, port, opts, timeout)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def recv(socket, length, timeout) do
|
||||
:ssl.recv(socket, length, timeout)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def send(socket, data) do
|
||||
:ssl.send(socket, data)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def setopts(socket, opts) do
|
||||
:ssl.setopts(socket, opts)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def getopts(socket, opts) do
|
||||
:ssl.getopts(socket, opts)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def getstat(socket) do
|
||||
:ssl.getstat(socket)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def getstat(socket, opt_names) do
|
||||
:ssl.getstat(socket, opt_names)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def controlling_process(socket, pid) do
|
||||
:ssl.controlling_process(socket, pid)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def peername(socket) do
|
||||
:ssl.peername(socket)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def sockname(socket) do
|
||||
:ssl.sockname(socket)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def shutdown(socket, mode) when mode in [:read, :write, :read_write] do
|
||||
:ssl.shutdown(socket, mode)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def close(socket) do
|
||||
:ssl.close(socket)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def listen(opts) do
|
||||
case Keyword.has_key?(opts, :cert) do
|
||||
true ->
|
||||
:ssl.listen(0, opts)
|
||||
|
||||
false ->
|
||||
throw("Please specify the cert key for the SSL transport")
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def accept(listen_socket, timeout) do
|
||||
:ssl.transport_accept(listen_socket, timeout)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def accept_ack(client_socket, timeout) do
|
||||
case :ssl.handshake(client_socket, timeout) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
{:ok, _, _} ->
|
||||
:ok
|
||||
|
||||
# abnormal data sent to socket
|
||||
{:error, {:tls_alert, _}} ->
|
||||
:ok = close(client_socket)
|
||||
exit(:normal)
|
||||
|
||||
# Socket most likely stopped responding, don't error out.
|
||||
{:error, reason} when reason in [:timeout, :closed] ->
|
||||
:ok = close(client_socket)
|
||||
exit(:normal)
|
||||
|
||||
{:error, reason} ->
|
||||
:ok = close(client_socket)
|
||||
throw(reason)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,104 @@
|
||||
defmodule Tortoise.Transport.Tcp do
|
||||
@moduledoc false
|
||||
|
||||
@behaviour Tortoise.Transport
|
||||
|
||||
alias Tortoise.Transport
|
||||
|
||||
@impl true
|
||||
def new(opts) do
|
||||
{host, opts} = Keyword.pop(opts, :host)
|
||||
{port, opts} = Keyword.pop(opts, :port, 1883)
|
||||
{list_opts, opts} = Keyword.pop(opts, :opts, [])
|
||||
host = coerce_host(host)
|
||||
opts = [:binary, {:packet, :raw}, {:active, false} | opts] ++ list_opts
|
||||
%Transport{type: __MODULE__, host: host, port: port, opts: opts}
|
||||
end
|
||||
|
||||
defp coerce_host(host) when is_binary(host) do
|
||||
String.to_charlist(host)
|
||||
end
|
||||
|
||||
defp coerce_host(otherwise) do
|
||||
otherwise
|
||||
end
|
||||
|
||||
@impl true
|
||||
def connect(host, port, opts, timeout) do
|
||||
# forced_opts = [:binary, active: false, packet: :raw]
|
||||
# opts = Keyword.merge(opts, forced_opts)
|
||||
:gen_tcp.connect(host, port, opts, timeout)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def recv(socket, length, timeout) do
|
||||
:gen_tcp.recv(socket, length, timeout)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def send(socket, data) do
|
||||
:gen_tcp.send(socket, data)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def setopts(socket, opts) do
|
||||
:inet.setopts(socket, opts)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def getopts(socket, opts) do
|
||||
:inet.getopts(socket, opts)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def getstat(socket) do
|
||||
:inet.getstat(socket)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def getstat(socket, opt_names) do
|
||||
:inet.getstat(socket, opt_names)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def controlling_process(socket, pid) do
|
||||
:gen_tcp.controlling_process(socket, pid)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def peername(socket) do
|
||||
:inet.peername(socket)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def sockname(socket) do
|
||||
:inet.sockname(socket)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def shutdown(socket, mode) when mode in [:read, :write, :read_write] do
|
||||
:gen_tcp.shutdown(socket, mode)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def close(socket) do
|
||||
:gen_tcp.close(socket)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def listen(opts) do
|
||||
# forced_opts = [:binary, active: false, packet: :raw, reuseaddr: true]
|
||||
# opts = Keyword.merge(opts, forced_opts)
|
||||
:gen_tcp.listen(0, opts)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def accept(listen_socket, timeout) do
|
||||
:gen_tcp.accept(listen_socket, timeout)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def accept_ack(_socket, _timeout) do
|
||||
:ok
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,86 @@
|
||||
defmodule Tortoise.MixProject do
|
||||
use Mix.Project
|
||||
|
||||
@version "0.9.9"
|
||||
|
||||
def project do
|
||||
[
|
||||
app: :tortoise,
|
||||
version: @version,
|
||||
elixir: "~> 1.6",
|
||||
start_permanent: Mix.env() == :prod,
|
||||
description: description(),
|
||||
package: package(),
|
||||
deps: deps(),
|
||||
dialyzer: dialyzer(),
|
||||
docs: docs(),
|
||||
test_coverage: [tool: ExCoveralls],
|
||||
preferred_cli_env: [
|
||||
coveralls: :test,
|
||||
"coveralls.detail": :test,
|
||||
"coveralls.html": :test,
|
||||
"coveralls.json": :test,
|
||||
"coveralls.post": :test,
|
||||
docs: :docs
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
defp description() do
|
||||
"""
|
||||
A MQTT client for Elixir.
|
||||
"""
|
||||
end
|
||||
|
||||
# Run "mix help compile.app" to learn about applications.
|
||||
def application do
|
||||
[
|
||||
extra_applications: [:logger, :ssl],
|
||||
mod: {Tortoise.App, []}
|
||||
]
|
||||
end
|
||||
|
||||
# Run "mix help deps" to learn about dependencies.
|
||||
defp deps do
|
||||
[
|
||||
{:gen_state_machine, "~> 2.0 or ~> 3.0"},
|
||||
{:dialyxir, "~> 1.0.0-rc.3", only: [:dev], runtime: false},
|
||||
{:eqc_ex, "~> 1.4", only: :test},
|
||||
{:excoveralls, "~> 0.10", only: :test},
|
||||
{:ex_doc, "~> 0.19", only: :dev, runtime: false},
|
||||
{:ct_helper, github: "ninenines/ct_helper", only: :test}
|
||||
]
|
||||
end
|
||||
|
||||
defp package() do
|
||||
[
|
||||
maintainers: ["Martin Gausby"],
|
||||
licenses: ["Apache 2.0"],
|
||||
files: ["lib", "mix.exs", "README*", "CHANGELOG*", "LICENSE*"],
|
||||
links: %{"GitHub" => "https://github.com/gausby/tortoise"}
|
||||
]
|
||||
end
|
||||
|
||||
defp docs() do
|
||||
[
|
||||
name: "Tortoise",
|
||||
source_ref: "v#{@version}",
|
||||
main: "introduction",
|
||||
canonical: "http://hexdocs.pm/tortoise",
|
||||
source_url: "https://github.com/gausby/tortoise",
|
||||
extras: [
|
||||
"docs/introduction.md",
|
||||
"docs/connecting_to_a_mqtt_broker.md",
|
||||
"docs/connection_supervision.md",
|
||||
"docs/publishing_messages.md"
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
defp dialyzer() do
|
||||
[
|
||||
ignore_warnings: "dialyzer.ignore",
|
||||
flags: [:error_handling, :race_conditions, :underspecs]
|
||||
]
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user