├── .formatter.exs ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── examples ├── hello_world_http.exs └── hello_world_tcp.exs ├── lib ├── jsonrpc2.ex └── jsonrpc2 │ ├── clients │ ├── http.ex │ ├── tcp.ex │ └── tcp │ │ └── protocol.ex │ ├── request.ex │ ├── response.ex │ ├── serializers │ └── jiffy.ex │ ├── server │ └── handler.ex │ └── servers │ ├── http.ex │ ├── http │ └── plug.ex │ ├── tcp.ex │ └── tcp │ └── protocol.ex ├── mix.exs ├── mix.lock └── test ├── jsonrpc2 ├── handler_test.exs ├── http_test.exs └── tcp_test.exs ├── support └── handlers.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | line_length: 108, 3 | rename_deprecated_at: "1.4.0", 4 | inputs: [".formatter.exs", "mix.exs", "{examples,lib,test}/**/*.{ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | 19 | /.elixir_ls -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: linux 2 | dist: bionic 3 | language: elixir 4 | elixir: 5 | - 1.8 6 | - 1.9 7 | - 1.10 8 | otp_release: 9 | - 21.2 10 | - 22.3.4 11 | # uncomment when jiffy fixes itself for 23 12 | # - 23.0.2 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 FanDuel Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/fanduel/jsonrpc2-elixir.svg?branch=master)](https://travis-ci.org/fanduel/jsonrpc2-elixir) 2 | 3 | # JSONRPC2 4 | 5 | JSON-RPC 2.0 for Elixir. 6 | 7 | Use the included TCP/TLS server/client, JSON-in-the-body HTTP(S) server/client, or bring your own transport. 8 | 9 | See the [`examples`](https://github.com/fanduel/jsonrpc2-elixir/tree/master/examples) directory as well as the [`JSONRPC2`](https://hexdocs.pm/jsonrpc2/JSONRPC2.html) docs for examples. 10 | 11 | To install, add `jsonrpc2` and `jason` to your list of dependencies in `mix.exs`: 12 | 13 | ```elixir 14 | def deps do 15 | [{:jsonrpc2, "~> 2.0"}, {:jason, "~> 1.0"}] 16 | end 17 | ``` 18 | 19 | ## **v2.0 Upgrade** 20 | 21 | ### tl;dr 22 | 23 | **The TCP/TLS server/client default packet format has changed in v2.0, causing backwards incompatibility.** 24 | 25 | If your existing servers/clients are working fine in `line_packet` mode, there is no need to change to the new packet format. 26 | 27 | **However, if you wish to upgrade existing servers/clients to v2.0 safely, you must now pass the `line_packet: true` option.** 28 | 29 | Here are some examples of adding the option for both server and client: 30 | 31 | ```elixir 32 | # Server option should be added to `opts` 33 | JSONRPC2.Servers.TCP.start_listener(handler, port, name: name, line_packet: true) 34 | JSONRPC2.Servers.TCP.child_spec(handler, port, name: name, line_packet: true) 35 | 36 | # Client option should be added to `client_opts` 37 | JSONRPC2.Clients.TCP.start(host, port, name, line_packet: true) 38 | ``` 39 | 40 | ### Why? 41 | 42 | The line-terminated packet format caused the size of the packet to be limited by the size of the socket's receive buffer, causing difficult to diagnose errors when the packet size passed the limit. 43 | 44 | The only downside of the new approach is a 4-byte overhead due to a new header indicating the size of the rest of the packet. 45 | 46 | In light of the fact that the smallest possible JSON-RPC 2.0 request is approximately 50 bytes, I have decided to make this format the new default in an attempt to follow the principle of least surprise. 47 | 48 | ### What if I want to switch to the new packet format, and uptime is a concern? 49 | 50 | Here's an example approach: 51 | 52 | 1. Update to v2.0, add `line_packet: true`, and deploy. 53 | 2. Add a new server listener for each existing one, on a different port, without `line_packet: true`, and deploy. 54 | 3. Update clients to use the new port, remove `line_packet: true`, and deploy. 55 | 4. Remove the original server listener for each one you created, and deploy. 56 | 57 | ## Serialization 58 | 59 | Uses `jason` by default, but you can use any serializer (it doesn't even have to be JSON, technically). 60 | 61 | A serializer for `jiffy` is included as `JSONRPC2.Serializers.Jiffy`, and legacy users can select `Poison` if they have included it as a dependency. 62 | 63 | To use a different serializer you must configure it in your Mix config. For the `jiffy` serializer: 64 | 65 | ```elixir 66 | config :jsonrpc2, :serializer, JSONRPC2.Serializers.Jiffy 67 | ``` 68 | 69 | If you are going to use the `jiffy` serializer, you must add it to your deps instead of `jason`: 70 | 71 | ```elixir 72 | def deps do 73 | [..., {:jiffy, "~> 1.0"}] 74 | end 75 | ``` 76 | 77 | If you use your own serializer, you do not (necessarily) need to add `jason` or `jiffy` to your deps. 78 | 79 | ## TCP/TLS server 80 | 81 | If you plan to use the TCP/TLS server, you also need to add `ranch` to your deps. 82 | 83 | ```elixir 84 | def deps do 85 | [..., {:ranch, "~> 1.7"}] 86 | end 87 | ``` 88 | 89 | ## TCP/TLS client 90 | 91 | If you plan to use the TCP/TLS client, you also need to add `shackle` to your deps/apps. 92 | 93 | ```elixir 94 | def deps do 95 | [..., {:shackle, "~> 0.5"}] 96 | end 97 | ``` 98 | 99 | ## HTTP(S) server 100 | 101 | If you plan to use the HTTP(S) server, you also need to add `plug`, `cowboy`, and `plug_cowboy` to your deps. 102 | 103 | ```elixir 104 | def deps do 105 | [..., {:plug, "~> 1.8"}, {:cowboy, "~> 2.6"}, {:plug_cowboy, "~> 2.0"}] 106 | end 107 | ``` 108 | 109 | ## HTTP(S) client 110 | 111 | If you plan to use the HTTP(S) client, you also need to add `hackney` to your deps. 112 | 113 | ```elixir 114 | def deps do 115 | [..., {:hackney, "~> 1.15"}] 116 | end 117 | ``` 118 | -------------------------------------------------------------------------------- /examples/hello_world_http.exs: -------------------------------------------------------------------------------- 1 | # Run with `MIX_ENV=test mix run hello_world_http.exs` 2 | 3 | # Ensure hackney is started (usually via mix.exs) 4 | Application.ensure_all_started(:hackney) 5 | 6 | # Define a handler 7 | defmodule Handler do 8 | use JSONRPC2.Server.Handler 9 | 10 | def handle_request("hello", [name]) do 11 | "Hello, #{name}!" 12 | end 13 | 14 | def handle_request("hello2", %{"name" => name}) do 15 | "Hello again, #{name}!" 16 | end 17 | 18 | def handle_request("notify", [name]) do 19 | IO.puts("You have been notified, #{name}!") 20 | end 21 | end 22 | 23 | # Start the server (this will usually go in your OTP application's start/2) 24 | JSONRPC2.Servers.HTTP.http(Handler) 25 | 26 | # Define the client 27 | defmodule Client do 28 | alias JSONRPC2.Clients.HTTP 29 | 30 | @url "http://localhost:4000/" 31 | 32 | def hello(name) do 33 | HTTP.call(@url, "hello", [name]) 34 | end 35 | 36 | def hello2(args) do 37 | HTTP.call(@url, "hello2", Map.new(args)) 38 | end 39 | 40 | def notify(name) do 41 | HTTP.notify(@url, "notify", [name]) 42 | end 43 | end 44 | 45 | # Make a call with the client to the server 46 | IO.inspect(Client.hello("Elixir")) 47 | # => {:ok, "Hello, Elixir!"} 48 | 49 | # Make a call with the client to the server, using named args 50 | IO.inspect(Client.hello2(name: "Elixir")) 51 | # => {:ok, "Hello again, Elixir!"} 52 | 53 | # Notifications 54 | Client.notify("Elixir") 55 | # => You have been notified, Elixir! 56 | -------------------------------------------------------------------------------- /examples/hello_world_tcp.exs: -------------------------------------------------------------------------------- 1 | # Run with `mix run hello_world_tcp.exs` 2 | 3 | # Ensure Ranch and shackle are started (usually via mix.exs) 4 | Application.ensure_all_started(:ranch) 5 | Application.ensure_all_started(:shackle) 6 | 7 | # Define a handler 8 | defmodule Handler do 9 | use JSONRPC2.Server.Handler 10 | 11 | def handle_request("hello", [name]) do 12 | "Hello, #{name}!" 13 | end 14 | 15 | def handle_request("hello2", %{"name" => name}) do 16 | "Hello again, #{name}!" 17 | end 18 | 19 | def handle_request("subtract", [minuend, subtrahend]) do 20 | minuend - subtrahend 21 | end 22 | 23 | def handle_request("notify", [name]) do 24 | IO.puts("You have been notified, #{name}!") 25 | end 26 | end 27 | 28 | # Start the server (this will usually go in your OTP application's start/2) 29 | JSONRPC2.Servers.TCP.start_listener(Handler, 8000) 30 | 31 | # Define the client 32 | defmodule Client do 33 | alias JSONRPC2.Clients.TCP 34 | 35 | def start(host, port) do 36 | TCP.start(host, port, __MODULE__) 37 | end 38 | 39 | def hello(name) do 40 | TCP.call(__MODULE__, "hello", [name]) 41 | end 42 | 43 | def hello2(args) do 44 | TCP.call(__MODULE__, "hello2", Map.new(args)) 45 | end 46 | 47 | def subtract(minuend, subtrahend) do 48 | TCP.cast(__MODULE__, "subtract", [minuend, subtrahend]) 49 | end 50 | 51 | def notify(name) do 52 | TCP.notify(__MODULE__, "notify", [name]) 53 | end 54 | end 55 | 56 | # Start the client pool (this will also usually go in your OTP application's start/2) 57 | Client.start("localhost", 8000) 58 | 59 | # Make a call with the client to the server 60 | IO.inspect(Client.hello("Elixir")) 61 | # => {:ok, "Hello, Elixir!"} 62 | 63 | # Make a call with the client to the server, using named args 64 | IO.inspect(Client.hello2(name: "Elixir")) 65 | # => {:ok, "Hello again, Elixir!"} 66 | 67 | # Make a call with the client to the server asynchronously 68 | {:ok, request_id} = Client.subtract(2, 1) 69 | IO.puts("non-blocking!") 70 | # => non-blocking! 71 | IO.inspect(JSONRPC2.Clients.TCP.receive_response(request_id)) 72 | # => {:ok, 1} 73 | 74 | # Notifications 75 | Client.notify("Elixir") 76 | # => You have been notified, Elixir! 77 | -------------------------------------------------------------------------------- /lib/jsonrpc2.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONRPC2 do 2 | @moduledoc ~S""" 3 | `JSONRPC2` is an Elixir library for JSON-RPC 2.0. 4 | 5 | It includes request and response utility modules, a transport-agnostic server handler, a 6 | line-based TCP server and client, which are based on [Ranch](https://github.com/ninenines/ranch) 7 | and [shackle](https://github.com/lpgauth/shackle), respectively, and a JSON-in-the-body HTTP(S) 8 | server and client, based on [Plug](https://github.com/elixir-lang/plug) and 9 | [hackney](https://github.com/benoitc/hackney), respectively. 10 | 11 | ## TCP Example 12 | 13 | # Define a handler 14 | defmodule Handler do 15 | use JSONRPC2.Server.Handler 16 | 17 | def handle_request("hello", [name]) do 18 | "Hello, #{name}!" 19 | end 20 | 21 | def handle_request("hello2", %{"name" => name}) do 22 | "Hello again, #{name}!" 23 | end 24 | 25 | def handle_request("subtract", [minuend, subtrahend]) do 26 | minuend - subtrahend 27 | end 28 | 29 | def handle_request("notify", [name]) do 30 | IO.puts "You have been notified, #{name}!" 31 | end 32 | end 33 | 34 | # Start the server (this will usually go in your OTP application's start/2) 35 | JSONRPC2.Servers.TCP.start_listener(Handler, 8000) 36 | 37 | # Define the client 38 | defmodule Client do 39 | alias JSONRPC2.Clients.TCP 40 | 41 | def start(host, port) do 42 | TCP.start(host, port, __MODULE__) 43 | end 44 | 45 | def hello(name) do 46 | TCP.call(__MODULE__, "hello", [name]) 47 | end 48 | 49 | def hello2(args) do 50 | TCP.call(__MODULE__, "hello2", Map.new(args)) 51 | end 52 | 53 | def subtract(minuend, subtrahend) do 54 | TCP.cast(__MODULE__, "subtract", [minuend, subtrahend]) 55 | end 56 | 57 | def notify(name) do 58 | TCP.notify(__MODULE__, "notify", [name]) 59 | end 60 | end 61 | 62 | # Start the client pool (this will also usually go in your OTP application's start/2) 63 | Client.start("localhost", 8000) 64 | 65 | # Make a call with the client to the server 66 | IO.inspect Client.hello("Elixir") 67 | #=> {:ok, "Hello, Elixir!"} 68 | 69 | # Make a call with the client to the server, using named args 70 | IO.inspect Client.hello2(name: "Elixir") 71 | #=> {:ok, "Hello again, Elixir!"} 72 | 73 | # Make a call with the client to the server asynchronously 74 | {:ok, request_id} = Client.subtract(2, 1) 75 | IO.puts "non-blocking!" 76 | #=> non-blocking! 77 | IO.inspect JSONRPC2.Clients.TCP.receive_response(request_id) 78 | #=> {:ok, 1} 79 | 80 | # Notifications 81 | Client.notify("Elixir") 82 | #=> You have been notified, Elixir! 83 | 84 | ## HTTP Example 85 | 86 | # Define a handler 87 | defmodule Handler do 88 | use JSONRPC2.Server.Handler 89 | 90 | def handle_request("hello", [name]) do 91 | "Hello, #{name}!" 92 | end 93 | 94 | def handle_request("hello2", %{"name" => name}) do 95 | "Hello again, #{name}!" 96 | end 97 | 98 | def handle_request("notify", [name]) do 99 | IO.puts "You have been notified, #{name}!" 100 | end 101 | end 102 | 103 | # Start the server (this will usually go in your OTP application's start/2) 104 | JSONRPC2.Servers.HTTP.http(Handler) 105 | 106 | # Define the client 107 | defmodule Client do 108 | alias JSONRPC2.Clients.HTTP 109 | 110 | @url "http://localhost:4000/" 111 | 112 | def hello(name) do 113 | HTTP.call(@url, "hello", [name]) 114 | end 115 | 116 | def hello2(args) do 117 | HTTP.call(@url, "hello2", Map.new(args)) 118 | end 119 | 120 | def notify(name) do 121 | HTTP.notify(@url, "notify", [name]) 122 | end 123 | end 124 | 125 | # Make a call with the client to the server 126 | IO.inspect Client.hello("Elixir") 127 | #=> {:ok, "Hello, Elixir!"} 128 | 129 | # Make a call with the client to the server, using named args 130 | IO.inspect Client.hello2(name: "Elixir") 131 | #=> {:ok, "Hello again, Elixir!"} 132 | 133 | # Notifications 134 | Client.notify("Elixir") 135 | #=> You have been notified, Elixir! 136 | 137 | ## Serializers 138 | 139 | Any module which conforms to the same API as Jason's `Jason.encode/1` and `Jason.decode/1` can 140 | be provided as a serializer to the functions which accept them. 141 | """ 142 | 143 | @typedoc "A JSON-RPC 2.0 method." 144 | @type method :: String.t() 145 | 146 | @typedoc "A decoded JSON object." 147 | @type json :: 148 | nil 149 | | true 150 | | false 151 | | float 152 | | integer 153 | | String.t() 154 | | [json] 155 | | %{optional(String.t()) => json} 156 | 157 | @typedoc "A JSON-RPC 2.0 params value." 158 | @type params :: [term] | %{optional(String.t() | atom()) => term} 159 | 160 | @typedoc "A JSON-RPC 2.0 request ID." 161 | @type id :: String.t() | number 162 | end 163 | -------------------------------------------------------------------------------- /lib/jsonrpc2/clients/http.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONRPC2.Clients.HTTP do 2 | @moduledoc """ 3 | A client for JSON-RPC 2.0 using an HTTP transport with JSON in the body. 4 | """ 5 | 6 | @default_headers [{"content-type", "application/json"}] 7 | 8 | @type batch_result :: {:ok, JSONRPC2.Response.id_and_response()} | {:error, any} 9 | 10 | @doc """ 11 | Make a call to `url` for JSON-RPC 2.0 `method` with `params`. 12 | 13 | You can also pass `headers`, `http_method`, `hackney_opts` to customize the options for 14 | hackney, and `request_id` for a custom JSON-RPC 2.0 request ID. 15 | 16 | See [hackney](https://github.com/benoitc/hackney) for more information on the available options. 17 | """ 18 | 19 | @spec call(String.t(), JSONRPC2.method(), JSONRPC2.params(), any, atom, list, JSONRPC2.id()) :: 20 | {:ok, any} | {:error, any} 21 | def call( 22 | url, 23 | method, 24 | params, 25 | headers \\ @default_headers, 26 | http_method \\ :post, 27 | hackney_opts \\ [], 28 | request_id \\ "0" 29 | ) do 30 | serializer = Application.get_env(:jsonrpc2, :serializer) 31 | {:ok, payload} = JSONRPC2.Request.serialized_request({method, params, request_id}, serializer) 32 | response = :hackney.request(http_method, url, headers, payload, hackney_opts) 33 | 34 | with( 35 | {:ok, 200, _headers, body_ref} <- response, 36 | {:ok, body} <- :hackney.body(body_ref), 37 | {:ok, {_id, result}} <- JSONRPC2.Response.deserialize_response(body, serializer) 38 | ) do 39 | result 40 | else 41 | {:ok, status_code, headers, body_ref} -> 42 | {:error, {:http_request_failed, status_code, headers, :hackney.body(body_ref)}} 43 | 44 | {:ok, status_code, headers} -> 45 | {:error, {:http_request_failed, status_code, headers}} 46 | 47 | {:error, reason} -> 48 | {:error, reason} 49 | end 50 | end 51 | 52 | @doc """ 53 | Notify via `url` for JSON-RPC 2.0 `method` with `params`. 54 | 55 | You can also pass `headers`, `http_method`, and `hackney_opts` to customize the options for 56 | hackney. 57 | 58 | See [hackney](https://github.com/benoitc/hackney) for more information on the available options. 59 | """ 60 | @spec notify(String.t(), JSONRPC2.method(), JSONRPC2.params(), any, atom, list) :: :ok | {:error, any} 61 | def notify(url, method, params, headers \\ @default_headers, http_method \\ :post, hackney_opts \\ []) do 62 | serializer = Application.get_env(:jsonrpc2, :serializer) 63 | {:ok, payload} = JSONRPC2.Request.serialized_request({method, params}, serializer) 64 | 65 | case :hackney.request(http_method, url, headers, payload, hackney_opts) do 66 | {:ok, 200, _headers, _body_ref} -> :ok 67 | {:ok, 200, _headers} -> :ok 68 | {:error, reason} -> {:error, reason} 69 | end 70 | end 71 | 72 | @doc """ 73 | Make a batch request via `url` for JSON-RPC 2.0 `requests`. 74 | 75 | You can also pass `headers`, `http_method`, and `hackney_opts` to customize the options for 76 | hackney. 77 | 78 | See [hackney](https://github.com/benoitc/hackney) for more information on the available options. 79 | """ 80 | @spec batch(String.t(), [JSONRPC2.Request.request()], any, atom, list) :: 81 | [batch_result] | :ok | {:error, any} 82 | def batch(url, requests, headers \\ @default_headers, http_method \\ :post, hackney_opts \\ []) do 83 | serializer = Application.get_env(:jsonrpc2, :serializer) 84 | 85 | {:ok, payload} = 86 | Enum.map(requests, &JSONRPC2.Request.request/1) 87 | |> serializer.encode() 88 | 89 | response = :hackney.request(http_method, url, headers, payload, hackney_opts) 90 | 91 | with( 92 | {:ok, 200, _headers, body_ref} <- response, 93 | {:ok, body} <- :hackney.body(body_ref), 94 | {:ok, deserialized_body} <- serializer.decode(body) 95 | ) do 96 | process_batch(deserialized_body) 97 | else 98 | {:ok, status_code, headers, body_ref} -> 99 | {:error, {:http_request_failed, status_code, headers, :hackney.body(body_ref)}} 100 | 101 | {:ok, 200, _headers} -> 102 | :ok 103 | 104 | {:ok, status_code, headers} -> 105 | {:error, {:http_request_failed, status_code, headers}} 106 | 107 | {:error, reason} -> 108 | {:error, reason} 109 | end 110 | end 111 | 112 | defp process_batch(responses) when is_list(responses) do 113 | Enum.map(responses, &JSONRPC2.Response.id_and_response/1) 114 | end 115 | 116 | defp process_batch(response) do 117 | JSONRPC2.Response.id_and_response(response) 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/jsonrpc2/clients/tcp.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONRPC2.Clients.TCP do 2 | @moduledoc """ 3 | A client for JSON-RPC 2.0 using a line-based TCP transport. 4 | """ 5 | 6 | alias JSONRPC2.Clients.TCP.Protocol 7 | 8 | @default_timeout 5_000 9 | 10 | @type host :: binary | :inet.socket_address() | :inet.hostname() 11 | 12 | @type request_id :: any 13 | 14 | @type call_option :: 15 | {:string_id, boolean} 16 | | {:timeout, pos_integer} 17 | 18 | @type call_options :: [call_option] 19 | 20 | @type cast_options :: [{:string_id, boolean}] 21 | 22 | @doc """ 23 | Start a client pool named `name`, connected to `host` at `port`. 24 | 25 | You can optionally pass `client_opts`, detailed 26 | [here](https://github.com/lpgauth/shackle#client_options), as well as `pool_opts`, detailed 27 | [here](https://github.com/lpgauth/shackle#pool_options). 28 | 29 | In addition to the `client_opts` above, you can also pass: 30 | * `line_packet` - by default, packets consist of a 4 byte header containing an unsigned integer 31 | in big-endian byte order specifying the number of bytes in the packet, followed by that 32 | number of bytes (equivalent to the 33 | [erlang inet packet type `4`](https://erlang.org/doc/man/inet.html#packet)). If set to 34 | `true`, packets will instead be terminated by line-endings, for compatibility with older 35 | implementations. 36 | """ 37 | @spec start(host, :inet.port_number(), atom, Keyword.t(), Keyword.t()) :: :ok 38 | def start(host, port, name, client_opts \\ [], pool_opts \\ []) do 39 | host = if is_binary(host), do: to_charlist(host), else: host 40 | 41 | ip = 42 | case host do 43 | host when is_list(host) -> 44 | case :inet.parse_address(host) do 45 | {:ok, ip} -> ip 46 | {:error, :einval} -> host 47 | end 48 | 49 | host -> 50 | host 51 | end 52 | 53 | line_packet = Keyword.get(client_opts, :line_packet) 54 | protocol = if line_packet, do: Protocol.LineTerminated, else: Protocol 55 | packet = if line_packet, do: :line, else: 4 56 | 57 | client_opts = 58 | Keyword.merge([ip: ip, port: port, socket_options: [:binary, packet: packet]], client_opts) 59 | 60 | :shackle_pool.start(name, protocol, client_opts, pool_opts) 61 | end 62 | 63 | @doc """ 64 | Stop the client pool with name `name`. 65 | """ 66 | @spec stop(atom) :: :ok | {:error, :shackle_not_started | :pool_not_started} 67 | def stop(name) do 68 | :shackle_pool.stop(name) 69 | end 70 | 71 | @doc """ 72 | Call the given `method` with `params` using the client pool named `name` with `options`. 73 | 74 | You can provide the option `string_id: true` for compatibility with pathological implementations, 75 | to force the request ID to be a string. 76 | 77 | You can also provide the option `timeout: 5_000` to set the timeout to 5000ms, for instance. 78 | 79 | For backwards compatibility reasons, you may also provide a boolean for the `options` parameter, 80 | which will set `string_id` to the given boolean. 81 | """ 82 | @spec call(atom, JSONRPC2.method(), JSONRPC2.params(), boolean | call_options) :: 83 | {:ok, any} | {:error, any} 84 | def call(name, method, params, options \\ []) 85 | 86 | def call(name, method, params, string_id) when is_boolean(string_id) do 87 | call(name, method, params, string_id: string_id) 88 | end 89 | 90 | def call(name, method, params, options) do 91 | string_id = Keyword.get(options, :string_id, false) 92 | timeout = Keyword.get(options, :timeout, @default_timeout) 93 | 94 | :shackle.call(name, {:call, method, params, string_id}, timeout) 95 | end 96 | 97 | @doc """ 98 | Asynchronously call the given `method` with `params` using the client pool named `name` with 99 | `options`. 100 | 101 | Use `receive_response/1` with the `request_id` to get the response. 102 | 103 | You can provide the option `string_id: true` for compatibility with pathological implementations, 104 | to force the request ID to be a string. 105 | 106 | You can also provide the option `timeout: 5_000` to set the timeout to 5000ms, for instance. 107 | 108 | Additionally, you may provide the option `pid: self()` in order to specify which process should 109 | be sent the message which is returned by `receive_response/1`. 110 | 111 | For backwards compatibility reasons, you may also provide a boolean for the `options` parameter, 112 | which will set `string_id` to the given boolean. 113 | """ 114 | @spec cast(atom, JSONRPC2.method(), JSONRPC2.params(), boolean | cast_options) :: 115 | {:ok, request_id} | {:error, :backlog_full} 116 | def cast(name, method, params, options \\ []) 117 | 118 | def cast(name, method, params, string_id) when is_boolean(string_id) do 119 | cast(name, method, params, string_id: string_id) 120 | end 121 | 122 | def cast(name, method, params, options) do 123 | string_id = Keyword.get(options, :string_id, false) 124 | timeout = Keyword.get(options, :timeout, @default_timeout) 125 | pid = Keyword.get(options, :pid, self()) 126 | 127 | :shackle.cast(name, {:call, method, params, string_id}, pid, timeout) 128 | end 129 | 130 | @doc """ 131 | Receive the response for a previous `cast/3` which returned a `request_id`. 132 | """ 133 | @spec receive_response(request_id) :: {:ok, any} | {:error, any} 134 | def receive_response(request_id) do 135 | :shackle.receive_response(request_id) 136 | end 137 | 138 | @doc """ 139 | Send a notification with the given `method` and `params` using the client pool named `name`. 140 | 141 | This function returns a `request_id`, but it should not be used with `receive_response/1`. 142 | """ 143 | @spec notify(atom, JSONRPC2.method(), JSONRPC2.params()) :: {:ok, request_id} | {:error, :backlog_full} 144 | def notify(name, method, params) do 145 | # Spawn a dead process so responses go to /dev/null 146 | pid = spawn(fn -> :ok end) 147 | :shackle.cast(name, {:notify, method, params}, pid, 0) 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /lib/jsonrpc2/clients/tcp/protocol.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONRPC2.Clients.TCP.Protocol do 2 | @moduledoc false 3 | 4 | if Code.ensure_loaded?(:shackle_client) do 5 | @behaviour :shackle_client 6 | end 7 | 8 | require Logger 9 | 10 | def init do 11 | serializer = Application.get_env(:jsonrpc2, :serializer) 12 | {:ok, %{request_counter: 0, serializer: serializer}} 13 | end 14 | 15 | def setup(_socket, state) do 16 | {:ok, state} 17 | end 18 | 19 | def handle_request({:call, method, params, string_id}, state) do 20 | external_request_id_int = external_request_id(state.request_counter) 21 | 22 | external_request_id = 23 | if string_id do 24 | Integer.to_string(external_request_id_int) 25 | else 26 | external_request_id_int 27 | end 28 | 29 | {:ok, data} = 30 | {method, params, external_request_id} 31 | |> JSONRPC2.Request.serialized_request(state.serializer) 32 | 33 | new_state = %{state | request_counter: external_request_id_int + 1} 34 | {:ok, external_request_id, data, new_state} 35 | end 36 | 37 | def handle_request({:notify, method, params}, state) do 38 | {:ok, data} = JSONRPC2.Request.serialized_request({method, params}, state.serializer) 39 | 40 | {:ok, nil, data, state} 41 | end 42 | 43 | def handle_data(data, state) do 44 | case JSONRPC2.Response.deserialize_response(data, state.serializer) do 45 | {:ok, {nil, result}} -> 46 | _ = 47 | Logger.error([ 48 | inspect(__MODULE__), 49 | " received response with null ID: ", 50 | inspect(result) 51 | ]) 52 | 53 | {:ok, [], state} 54 | 55 | {:ok, {id, result}} -> 56 | {:ok, [{id, result}], state} 57 | 58 | {:error, error} -> 59 | _ = 60 | Logger.error([ 61 | inspect(__MODULE__), 62 | " received invalid response, error: ", 63 | inspect(error) 64 | ]) 65 | 66 | {:ok, [], state} 67 | end 68 | end 69 | 70 | def terminate(_state) do 71 | :ok 72 | end 73 | 74 | parent = __MODULE__ 75 | 76 | defmodule LineTerminated do 77 | @moduledoc false 78 | 79 | defdelegate init, to: parent 80 | 81 | defdelegate setup(socket, state), to: parent 82 | 83 | def handle_request(request, state) do 84 | {:ok, external_request_id, data, state} = unquote(parent).handle_request(request, state) 85 | {:ok, external_request_id, [data, "\r\n"], state} 86 | end 87 | 88 | defdelegate handle_data(data, state), to: parent 89 | defdelegate terminate(state), to: parent 90 | end 91 | 92 | defp external_request_id(request_counter) do 93 | rem(request_counter, 2_147_483_647) 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/jsonrpc2/request.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONRPC2.Request do 2 | @moduledoc """ 3 | JSON-RPC 2.0 Request object utilites. 4 | """ 5 | 6 | @type request :: 7 | {JSONRPC2.method(), JSONRPC2.params()} 8 | | {JSONRPC2.method(), JSONRPC2.params(), JSONRPC2.id()} 9 | 10 | @doc """ 11 | Returns a serialized `request` using `serializer`. 12 | 13 | See the README for more information on serializers. 14 | """ 15 | @spec serialized_request(request, module) :: {:ok, String.t()} | {:error, any} 16 | def serialized_request(request, serializer) do 17 | serializer.encode(request(request)) 18 | end 19 | 20 | @doc """ 21 | Returns a map representing the JSON-RPC2.0 request object for `request`. 22 | """ 23 | @spec request(request) :: map 24 | def request(request) 25 | 26 | def request({method, params}) 27 | when is_binary(method) and (is_list(params) or is_map(params)) do 28 | %{ 29 | "jsonrpc" => "2.0", 30 | "method" => method, 31 | "params" => params 32 | } 33 | end 34 | 35 | def request({method, params, id}) 36 | when is_number(id) or is_binary(id) do 37 | {method, params} 38 | |> request() 39 | |> Map.put("id", id) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/jsonrpc2/response.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONRPC2.Response do 2 | @moduledoc """ 3 | JSON-RPC 2.0 Response object utilites. 4 | """ 5 | 6 | @type id_and_response :: 7 | {JSONRPC2.id() | nil, {:ok, any} | {:error, code :: integer, message :: String.t(), data :: any}} 8 | 9 | @doc """ 10 | Deserialize the given `response` using `serializer`. 11 | """ 12 | @spec deserialize_response(String.t(), module) :: {:ok, id_and_response} | {:error, any} 13 | def deserialize_response(response, serializer) do 14 | case serializer.decode(response) do 15 | {:ok, response} -> id_and_response(response) 16 | {:error, error} -> {:error, error} 17 | {:error, error, _} -> {:error, error} 18 | end 19 | end 20 | 21 | @doc """ 22 | Returns a tuple containing the information contained in `response`. 23 | """ 24 | @spec id_and_response(map) :: {:ok, id_and_response} | {:error, any} 25 | def id_and_response(%{"jsonrpc" => "2.0", "id" => id, "result" => result}) do 26 | {:ok, {id, {:ok, result}}} 27 | end 28 | 29 | def id_and_response(%{"jsonrpc" => "2.0", "id" => id, "error" => error}) do 30 | {:ok, {id, {:error, {error["code"], error["message"], error["data"]}}}} 31 | end 32 | 33 | def id_and_response(response) do 34 | {:error, {:invalid_response, response}} 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/jsonrpc2/serializers/jiffy.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONRPC2.Serializers.Jiffy do 2 | @moduledoc false 3 | 4 | def decode(json) do 5 | try do 6 | {:ok, :jiffy.decode(json, [:return_maps, :use_nil])} 7 | catch 8 | kind, payload -> {:error, {kind, payload}} 9 | end 10 | end 11 | 12 | def encode(json) do 13 | try do 14 | {:ok, :jiffy.encode(json, [:use_nil])} 15 | catch 16 | kind, payload -> {:error, {kind, payload}} 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/jsonrpc2/server/handler.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONRPC2.Server.Handler do 2 | @moduledoc """ 3 | A transport-agnostic server handler for JSON-RPC 2.0. 4 | 5 | ## Example 6 | 7 | defmodule SpecHandler do 8 | use JSONRPC2.Server.Handler 9 | 10 | def handle_request("subtract", [x, y]) do 11 | x - y 12 | end 13 | 14 | def handle_request("subtract", %{"minuend" => x, "subtrahend" => y}) do 15 | x - y 16 | end 17 | 18 | def handle_request("update", _) do 19 | :ok 20 | end 21 | 22 | def handle_request("sum", numbers) do 23 | Enum.sum(numbers) 24 | end 25 | 26 | def handle_request("get_data", []) do 27 | ["hello", 5] 28 | end 29 | end 30 | 31 | SpecHandler.handle(~s({"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1})) 32 | #=> ~s({"jsonrpc": "2.0", "result": 19, "id": 1}) 33 | """ 34 | 35 | require Logger 36 | 37 | @doc """ 38 | Respond to a request for `method` with `params`. 39 | 40 | You can return any serializable result (which will be ignored for notifications), or you can throw 41 | these values to produce error responses: 42 | 43 | * `:method_not_found`, `:invalid_params`, `:internal_error`, `:server_error` 44 | * any of the above, in a tuple like `{:method_not_found, %{my_error_data: 1}}` to return extra 45 | data 46 | * `{:jsonrpc2, code, message}` or `{:jsonrpc2, code, message, data}` to return a custom error, 47 | with or without extra data. 48 | """ 49 | @callback handle_request(method :: JSONRPC2.method(), params :: JSONRPC2.params()) :: 50 | JSONRPC2.json() | no_return 51 | 52 | defmacro __using__(_) do 53 | quote do 54 | @spec handle(String.t()) :: {:reply, String.t()} | :noreply 55 | def handle(json) do 56 | serializer = Application.get_env(:jsonrpc2, :serializer) 57 | 58 | unquote(__MODULE__).handle(__MODULE__, serializer, json) 59 | end 60 | end 61 | end 62 | 63 | @doc false 64 | def handle(module, serializer, json) when is_binary(json) do 65 | case serializer.decode(json) do 66 | {:ok, decoded_request} -> 67 | parse(decoded_request) |> collate_for_dispatch(module) 68 | 69 | {:error, _error} -> 70 | standard_error_response(:parse_error, nil) 71 | 72 | {:error, :invalid, _number} -> 73 | standard_error_response(:parse_error, nil) 74 | end 75 | |> encode_response(module, serializer, json) 76 | end 77 | 78 | def handle(module, serializer, json) do 79 | parse(json) 80 | |> collate_for_dispatch(module) 81 | |> encode_response(module, serializer, json) 82 | end 83 | 84 | defp collate_for_dispatch(batch_rpc, module) when is_list(batch_rpc) and length(batch_rpc) > 0 do 85 | merge_responses(Enum.map(batch_rpc, &dispatch(module, &1))) 86 | end 87 | 88 | defp collate_for_dispatch(rpc, module) do 89 | dispatch(module, rpc) 90 | end 91 | 92 | defp parse(requests) when is_list(requests) do 93 | for request <- requests, do: parse(request) 94 | end 95 | 96 | defp parse(request) when is_map(request) do 97 | version = Map.get(request, "jsonrpc", :undefined) 98 | method = Map.get(request, "method", :undefined) 99 | params = Map.get(request, "params", []) 100 | id = Map.get(request, "id", :undefined) 101 | 102 | if valid_request?(version, method, params, id) do 103 | {method, params, id} 104 | else 105 | :invalid_request 106 | end 107 | end 108 | 109 | defp parse(_) do 110 | :invalid_request 111 | end 112 | 113 | defp valid_request?(version, method, params, id) do 114 | version == "2.0" and is_binary(method) and (is_list(params) or is_map(params)) and 115 | (id in [:undefined, nil] or is_binary(id) or is_number(id)) 116 | end 117 | 118 | defp merge_responses(responses) do 119 | case for({:reply, reply} <- responses, do: reply) do 120 | [] -> :noreply 121 | replies -> {:reply, replies} 122 | end 123 | end 124 | 125 | @throwable_errors [:method_not_found, :invalid_params, :internal_error, :server_error] 126 | 127 | defp dispatch(module, {method, params, id}) do 128 | try do 129 | result_response(module.handle_request(method, params), id) 130 | rescue 131 | e in FunctionClauseError -> 132 | # if that error originates from the very module.handle_request call - handle, otherwise - reraise 133 | case e do 134 | %FunctionClauseError{function: :handle_request, module: ^module} -> 135 | standard_error_response(:method_not_found, %{method: method, params: params}, id) 136 | 137 | other_e -> 138 | stacktrace = System.stacktrace() 139 | log_error(module, method, params, :error, other_e, stacktrace) 140 | Kernel.reraise(other_e, stacktrace) 141 | end 142 | catch 143 | :throw, error when error in @throwable_errors -> 144 | standard_error_response(error, id) 145 | 146 | :throw, {error, data} when error in @throwable_errors -> 147 | standard_error_response(error, data, id) 148 | 149 | :throw, {:jsonrpc2, code, message} when is_integer(code) and is_binary(message) -> 150 | error_response(code, message, id) 151 | 152 | :throw, {:jsonrpc2, code, message, data} when is_integer(code) and is_binary(message) -> 153 | error_response(code, message, data, id) 154 | 155 | kind, payload -> 156 | stacktrace = System.stacktrace() 157 | log_error(module, method, params, kind, payload, stacktrace) 158 | 159 | standard_error_response(:internal_error, id) 160 | end 161 | end 162 | 163 | defp dispatch(_module, _rpc) do 164 | standard_error_response(:invalid_request, nil) 165 | end 166 | 167 | defp log_error(module, method, params, kind, payload, stacktrace) do 168 | _ = 169 | Logger.error([ 170 | "Error in handler ", 171 | inspect(module), 172 | " for method ", 173 | method, 174 | " with params: ", 175 | inspect(params), 176 | ":\n\n", 177 | Exception.format(kind, payload, stacktrace) 178 | ]) 179 | end 180 | 181 | defp result_response(_result, :undefined) do 182 | :noreply 183 | end 184 | 185 | defp result_response(result, id) do 186 | {:reply, 187 | %{ 188 | "jsonrpc" => "2.0", 189 | "result" => result, 190 | "id" => id 191 | }} 192 | end 193 | 194 | defp standard_error_response(error_type, id) do 195 | {code, message} = error_code_and_message(error_type) 196 | error_response(code, message, id) 197 | end 198 | 199 | defp standard_error_response(error_type, data, id) do 200 | {code, message} = error_code_and_message(error_type) 201 | error_response(code, message, data, id) 202 | end 203 | 204 | defp error_response(_code, _message, _data, :undefined) do 205 | :noreply 206 | end 207 | 208 | defp error_response(code, message, data, id) do 209 | {:reply, error_reply(code, message, data, id)} 210 | end 211 | 212 | defp error_response(_code, _message, :undefined) do 213 | :noreply 214 | end 215 | 216 | defp error_response(code, message, id) do 217 | {:reply, error_reply(code, message, id)} 218 | end 219 | 220 | defp error_reply(code, message, data, id) do 221 | %{ 222 | "jsonrpc" => "2.0", 223 | "error" => %{ 224 | "code" => code, 225 | "message" => message, 226 | "data" => data 227 | }, 228 | "id" => id 229 | } 230 | end 231 | 232 | defp error_reply(code, message, id) do 233 | %{ 234 | "jsonrpc" => "2.0", 235 | "error" => %{ 236 | "code" => code, 237 | "message" => message 238 | }, 239 | "id" => id 240 | } 241 | end 242 | 243 | defp error_code_and_message(:parse_error), do: {-32700, "Parse error"} 244 | defp error_code_and_message(:invalid_request), do: {-32600, "Invalid Request"} 245 | defp error_code_and_message(:method_not_found), do: {-32601, "Method not found"} 246 | defp error_code_and_message(:invalid_params), do: {-32602, "Invalid params"} 247 | defp error_code_and_message(:internal_error), do: {-32603, "Internal error"} 248 | defp error_code_and_message(:server_error), do: {-32000, "Server error"} 249 | 250 | defp encode_response(:noreply, _module, _serializer, _json) do 251 | :noreply 252 | end 253 | 254 | defp encode_response({:reply, reply}, module, serializer, json) do 255 | case serializer.encode(reply) do 256 | {:ok, encoded_reply} -> 257 | {:reply, encoded_reply} 258 | 259 | {:error, reason} -> 260 | _ = 261 | Logger.info([ 262 | "Handler ", 263 | inspect(module), 264 | " returned invalid reply:\n Reason: ", 265 | inspect(reason), 266 | "\n Received: ", 267 | inspect(reply), 268 | "\n Request: ", 269 | json 270 | ]) 271 | 272 | standard_error_response(:internal_error, nil) 273 | |> encode_response(module, serializer, json) 274 | end 275 | end 276 | end 277 | -------------------------------------------------------------------------------- /lib/jsonrpc2/servers/http.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONRPC2.Servers.HTTP do 2 | @moduledoc """ 3 | An HTTP server which responds to POSTed JSON-RPC 2.0 in the request body. 4 | 5 | This server will respond to all requests on the given port. If you wish to mount a JSON-RPC 2.0 6 | handler within a Plug-based web app (such as Phoenix), please see `JSONRPC2.Servers.HTTP.Plug`. 7 | """ 8 | 9 | alias JSONRPC2.Servers.HTTP.Plug, as: JSONRPC2Plug 10 | 11 | @doc """ 12 | Returns a supervisor child spec for the given `handler` via `scheme` with `cowboy_opts`. 13 | 14 | Allows you to embed a server directly in your app's supervision tree, rather than letting 15 | Plug/Cowboy handle it. 16 | 17 | Please see the docs for [Plug.Cowboy](https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html) for the values 18 | which are allowed in `cowboy_opts`. 19 | 20 | If the server `ref` is not set in `cowboy_opts`, `handler.HTTP` or `handler.HTTPS` is the default. 21 | """ 22 | @spec child_spec(:http | :https, module, list) :: Supervisor.Spec.spec() 23 | def child_spec(scheme, handler, cowboy_opts \\ []) do 24 | cowboy_opts = cowboy_opts ++ [ref: ref(scheme, handler)] 25 | cowboy_adapter().child_spec(scheme: scheme, plug: {JSONRPC2Plug, handler}, options: cowboy_opts) 26 | end 27 | 28 | @doc """ 29 | Starts an HTTP server for the given `handler` with `cowboy_opts`. 30 | 31 | Please see the docs for [Plug.Cowboy](https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html) for the values 32 | which are allowed in `cowboy_opts`. 33 | 34 | If the server `ref` is not set in `cowboy_opts`, `handler.HTTP` is the default. 35 | """ 36 | @spec http(module, list) :: {:ok, pid} | {:error, term} 37 | def http(handler, cowboy_opts \\ []) do 38 | cowboy_opts = cowboy_opts ++ [ref: ref(:http, handler)] 39 | cowboy_adapter().http(JSONRPC2Plug, handler, cowboy_opts) 40 | end 41 | 42 | @doc """ 43 | Starts an HTTPS server for the given `handler` with `cowboy_opts`. 44 | 45 | Please see the docs for [Plug.Cowboy](https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html) for the values 46 | which are allowed in `cowboy_opts`. In addition to the normal `cowboy_opts`, this function also 47 | accepts the same extra SSL-related options as 48 | [Plug.Cowboy.https/3](https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html#https/3). 49 | 50 | If the server `ref` is not set in `cowboy_opts`, `handler.HTTPS` is the default. 51 | """ 52 | @spec https(module, list) :: {:ok, pid} | {:error, term} 53 | def https(handler, cowboy_opts \\ []) do 54 | cowboy_opts = cowboy_opts ++ [ref: ref(:https, handler)] 55 | cowboy_adapter().https(JSONRPC2Plug, handler, cowboy_opts) 56 | end 57 | 58 | defp ref(scheme, handler) do 59 | case scheme do 60 | :http -> [handler, HTTP] 61 | :https -> [handler, HTTPS] 62 | end 63 | |> Module.concat() 64 | end 65 | 66 | @doc """ 67 | Shut down an existing server with given `ref`. 68 | """ 69 | @spec shutdown(atom) :: :ok | {:error, :not_found} 70 | def shutdown(ref) do 71 | cowboy_adapter().shutdown(ref) 72 | end 73 | 74 | defp cowboy_adapter() do 75 | cowboy_spec = 76 | Application.loaded_applications() 77 | |> List.keyfind(:cowboy, 0) 78 | 79 | if cowboy_spec do 80 | cowboy_spec 81 | |> elem(2) 82 | |> List.to_string() 83 | |> Version.parse!() 84 | |> Version.match?("~> 2.0") 85 | |> case do 86 | true -> Plug.Cowboy 87 | false -> Plug.Adapters.Cowboy 88 | end 89 | else 90 | :ok = Application.load(:cowboy) 91 | 92 | cowboy_adapter() 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/jsonrpc2/servers/http/plug.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Plug.Builder) do 2 | defmodule JSONRPC2.Servers.HTTP.Plug do 3 | @moduledoc """ 4 | A plug that responds to POSTed JSON-RPC 2.0 in the request body. 5 | 6 | If you wish to start a standalone server which will respond to JSON-RPC 2.0 7 | POSTs at any URL, please see `JSONRPC2.Servers.HTTP`. 8 | 9 | If you wish to mount a JSON-RPC 2.0 handler in an existing Plug-based web 10 | application (such as Phoenix), you can do so by putting this in your router: 11 | 12 | forward "/jsonrpc", JSONRPC2.Servers.HTTP.Plug, YourJSONRPC2HandlerModule 13 | 14 | The above code will mount the handler `YourJSONRPC2HandlerModule` at the path 15 | "/jsonrpc". 16 | 17 | The `Plug.Parsers` module for JSON is automatically included in the pipeline, 18 | and will use the same serializer as is defined in the `:serializer` key of the 19 | `:jsonrpc2` application. You can override the default options (which are used 20 | in this example) like so: 21 | 22 | forward "/jsonrpc", JSONRPC2.Servers.HTTP.Plug, [ 23 | handler: YourJSONRPC2HandlerModule, 24 | plug_parsers_opts: [ 25 | parsers: [:json], 26 | pass: ["*/*"], 27 | json_decoder: Application.get_env(:jsonrpc2, :serializer) 28 | ] 29 | ] 30 | """ 31 | 32 | use Plug.Builder 33 | 34 | def init(opts) when is_list(opts) do 35 | Keyword.merge( 36 | [ 37 | plug_parsers_opts: [ 38 | parsers: [:json], 39 | pass: ["*/*"], 40 | json_decoder: Application.get_env(:jsonrpc2, :serializer) 41 | ] 42 | ], 43 | opts 44 | ) 45 | |> Map.new() 46 | end 47 | 48 | def init(handler) when is_atom(handler) do 49 | init(handler: handler) 50 | end 51 | 52 | plug(:wrap_plug_parsers, builder_opts()) 53 | plug(:handle_jsonrpc2, builder_opts()) 54 | 55 | @doc false 56 | def wrap_plug_parsers(conn, %{plug_parsers_opts: plug_parsers_opts}) do 57 | Plug.Parsers.call(conn, Plug.Parsers.init(plug_parsers_opts)) 58 | end 59 | 60 | @doc false 61 | def handle_jsonrpc2(%{method: "POST", body_params: body_params} = conn, opts) do 62 | handle_jsonrpc2(conn, body_params, opts) 63 | end 64 | 65 | def handle_jsonrpc2(conn, _opts) do 66 | resp(conn, 404, "") 67 | end 68 | 69 | defp handle_jsonrpc2(conn, %Plug.Conn.Unfetched{}, opts) do 70 | {body, conn} = get_body(conn) 71 | do_handle_jsonrpc2(conn, body, opts) 72 | end 73 | 74 | defp handle_jsonrpc2(conn, %{"_json" => body_params}, opts), 75 | do: do_handle_jsonrpc2(conn, body_params, opts) 76 | 77 | defp handle_jsonrpc2(conn, body_params, opts), do: do_handle_jsonrpc2(conn, body_params, opts) 78 | 79 | defp do_handle_jsonrpc2(conn, body_params, %{handler: handler}) do 80 | resp_body = 81 | case handler.handle(body_params) do 82 | {:reply, reply} -> reply 83 | :noreply -> "" 84 | end 85 | 86 | conn 87 | |> put_resp_header("content-type", "application/json") 88 | |> resp(200, resp_body) 89 | end 90 | 91 | defp get_body(so_far \\ [], conn) do 92 | case read_body(conn) do 93 | {:ok, body, conn} -> 94 | {IO.iodata_to_binary([so_far | body]), conn} 95 | 96 | {:more, partial_body, conn} -> 97 | get_body([so_far | partial_body], conn) 98 | 99 | {:error, reason} -> 100 | raise Plug.Parsers.ParseError, exception: Exception.normalize(:error, reason) 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/jsonrpc2/servers/tcp.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONRPC2.Servers.TCP do 2 | @moduledoc """ 3 | A server for JSON-RPC 2.0 using a line-based TCP transport. 4 | """ 5 | 6 | alias JSONRPC2.Servers.TCP.Protocol 7 | 8 | @default_timeout 1000 * 60 * 60 9 | 10 | @doc """ 11 | Start a server with the given `handler` on `port` with `opts`. 12 | 13 | Available options: 14 | * `name` - a unique name that can be used to stop the server later. Defaults to the value of 15 | `handler`. 16 | * `num_acceptors` - number of acceptor processes to start. Defaults to 100. 17 | * `transport` - ranch transport to use. Defaults to `:ranch_tcp`. 18 | * `transport_opts` - ranch transport options. For `:ranch_tcp`, see 19 | [here](http://ninenines.eu/docs/en/ranch/1.7/manual/ranch_tcp/). 20 | * `timeout` - disconnect after this amount of milliseconds without a packet from a client. 21 | Defaults to 1 hour. 22 | * `line_packet` - by default, packets consist of a 4 byte header containing an unsigned integer 23 | in big-endian byte order specifying the number of bytes in the packet, followed by that 24 | number of bytes (equivalent to the 25 | [erlang inet packet type `4`](https://erlang.org/doc/man/inet.html#packet)). If set to 26 | `true`, packets will instead be terminated by line-endings, for compatibility with older 27 | implementations. 28 | """ 29 | @spec start_listener(module, :inet.port_number(), Keyword.t()) :: {:ok, pid} 30 | def start_listener(handler, port, opts \\ []) do 31 | apply(:ranch, :start_listener, ranch_args(handler, port, opts)) 32 | end 33 | 34 | @doc """ 35 | Returns a supervisor child spec for the given `handler` on `port` with `opts`. 36 | 37 | Allows you to embed a server directly in your app's supervision tree, rather 38 | than letting Ranch handle it. 39 | 40 | See `start_listener/3` for available options. 41 | """ 42 | @spec child_spec(module, :inet.port_number(), Keyword.t()) :: {:ok, pid} 43 | def child_spec(handler, port, opts \\ []) do 44 | apply(:ranch, :child_spec, ranch_args(handler, port, opts)) 45 | end 46 | 47 | @doc """ 48 | Stop the server with `name`. 49 | """ 50 | @spec stop(atom) :: :ok | {:error, :not_found} 51 | def stop(name) do 52 | :ranch.stop_listener(name) 53 | end 54 | 55 | defp ranch_args(handler, port, opts) do 56 | name = Keyword.get(opts, :name, handler) 57 | num_acceptors = Keyword.get(opts, :num_acceptors, 100) 58 | transport = Keyword.get(opts, :transport, :ranch_tcp) 59 | transport_opts = [port: port] ++ Keyword.get(opts, :transport_opts, []) 60 | timeout = Keyword.get(opts, :timeout, @default_timeout) 61 | line_packet = !!Keyword.get(opts, :line_packet) 62 | protocol_opts = {handler, timeout, line_packet} 63 | 64 | [name, num_acceptors, transport, transport_opts, Protocol, protocol_opts] 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/jsonrpc2/servers/tcp/protocol.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONRPC2.Servers.TCP.Protocol do 2 | @moduledoc false 3 | 4 | use GenServer 5 | require Logger 6 | 7 | if Code.ensure_loaded?(:ranch_protocol) do 8 | @behaviour :ranch_protocol 9 | end 10 | 11 | def start_link(ref, socket, transport, {jsonrpc2_handler, timeout, line_packet}) do 12 | :proc_lib.start_link(__MODULE__, :init, [ 13 | {ref, socket, transport, jsonrpc2_handler, timeout, line_packet} 14 | ]) 15 | end 16 | 17 | def init({ref, socket, transport, jsonrpc2_handler, timeout, line_packet}) do 18 | :ok = :proc_lib.init_ack({:ok, self()}) 19 | :ok = :ranch.accept_ack(ref) 20 | :ok = transport.setopts(socket, active: :once, packet: if(line_packet, do: :line, else: 4)) 21 | state = {ref, socket, transport, jsonrpc2_handler, timeout, line_packet} 22 | :gen_server.enter_loop(__MODULE__, [], state, timeout) 23 | end 24 | 25 | def handle_info({:tcp, socket, data}, state) do 26 | {_ref, _socket, transport, jsonrpc2_handler, timeout, line_packet} = state 27 | transport.setopts(socket, active: :once) 28 | 29 | {:ok, _} = 30 | Task.start(fn -> 31 | case jsonrpc2_handler.handle(data) do 32 | {:reply, reply} -> transport.send(socket, terminate_packet(reply, line_packet)) 33 | :noreply -> :noreply 34 | end 35 | end) 36 | 37 | {:noreply, state, timeout} 38 | end 39 | 40 | def handle_info({:tcp_closed, _socket}, state), 41 | do: {:stop, :normal, state} 42 | 43 | def handle_info({:tcp_error, _, reason}, state), 44 | do: {:stop, reason, state} 45 | 46 | def handle_info(:timeout, state), 47 | do: {:stop, :normal, state} 48 | 49 | def handle_info(message, state) do 50 | _ = 51 | Logger.info([ 52 | inspect(__MODULE__), 53 | " with state:\n", 54 | inspect(state), 55 | "\nreceived unexpected message:\n", 56 | inspect(message) 57 | ]) 58 | 59 | {:noreply, state} 60 | end 61 | 62 | defp terminate_packet(reply, true), do: [reply, "\r\n"] 63 | defp terminate_packet(reply, false), do: reply 64 | end 65 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule JSONRPC2.Mixfile do 2 | use Mix.Project 3 | 4 | @version "2.0.0" 5 | 6 | def project do 7 | [ 8 | app: :jsonrpc2, 9 | version: @version, 10 | elixir: "~> 1.8", 11 | deps: deps(), 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | description: description(), 14 | package: package(), 15 | name: "JSONRPC2", 16 | docs: [ 17 | source_ref: "v#{@version}", 18 | main: "readme", 19 | canonical: "http://hexdocs.pm/jsonrpc2", 20 | source_url: "https://github.com/fanduel/jsonrpc2-elixir", 21 | extras: ["README.md"] 22 | ], 23 | xref: [ 24 | exclude: [ 25 | Poison, 26 | :hackney, 27 | :jiffy, 28 | :ranch, 29 | :shackle, 30 | :shackle_pool, 31 | Plug.Conn, 32 | Plug.Adapters.Cowboy, 33 | Plug.Cowboy 34 | ] 35 | ] 36 | ] 37 | end 38 | 39 | def application do 40 | [extra_applications: [:logger], env: [serializer: Jason]] 41 | end 42 | 43 | defp deps do 44 | [ 45 | {:jason, "~> 1.0", optional: true}, 46 | {:poison, "~> 4.0 or ~> 3.0 or ~> 2.0", optional: true}, 47 | {:jiffy, "~> 1.0 or ~> 0.14", optional: true}, 48 | {:shackle, "~> 0.3", optional: true}, 49 | {:ranch, "~> 1.2", optional: true}, 50 | {:hackney, "~> 1.6", optional: true}, 51 | {:plug, "~> 1.3", optional: true}, 52 | {:plug_cowboy, "~> 2.0", optional: true}, 53 | {:cowboy, "~> 2.4 or ~> 1.1", optional: true}, 54 | {:ex_doc, "~> 0.20", only: :dev} 55 | ] 56 | end 57 | 58 | defp elixirc_paths(:test), do: ["lib", "test/support"] 59 | defp elixirc_paths(_), do: ["lib"] 60 | 61 | defp description do 62 | "JSON-RPC 2.0 for Elixir." 63 | end 64 | 65 | defp package do 66 | [ 67 | licenses: ["Apache 2.0"], 68 | links: %{ 69 | "Important v2.0 Upgrade Information" => "https://hexdocs.pm/jsonrpc2/readme.html#v2-0-upgrade", 70 | "GitHub" => "https://github.com/fanduel/jsonrpc2-elixir" 71 | }, 72 | files: ~w(mix.exs README.md LICENSE lib) 73 | ] 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"}, 3 | "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"}, 4 | "cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"}, 5 | "earmark": {:hex, :earmark, "1.4.5", "62ffd3bd7722fb7a7b1ecd2419ea0b458c356e7168c1f5d65caf09b4fbdd13c8", [:mix], [], "hexpm", "b7d0e6263d83dc27141a523467799a685965bf8b13b6743413f19a7079843f4f"}, 6 | "ex_doc": {:hex, :ex_doc, "0.22.1", "9bb6d51508778193a4ea90fa16eac47f8b67934f33f8271d5e1edec2dc0eee4c", [:mix], [{:earmark, "~> 1.4.0", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "d957de1b75cb9f78d3ee17820733dc4460114d8b1e11f7ee4fd6546e69b1db60"}, 7 | "granderl": {:hex, :granderl, "0.1.5", "f20077a68bd80b8d8783bd15a052813c6483771dec1a5b837d307cbe92f14122", [:rebar3], [], "hexpm", "0641473f29bc3211c832a6dd3adaa04544a5dffc1c62372556946f236df2dad6"}, 8 | "hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"}, 9 | "idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"}, 10 | "jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"}, 11 | "jiffy": {:hex, :jiffy, "1.0.4", "72adeff75c52a2ff07de738f0813768abe7ce158026cc1115a170340259c0caa", [:rebar3], [], "hexpm", "113e5299ee4e6b9f40204256d7bbbd1caf646edeaef31ef0f7f5f842c0dad39e"}, 12 | "makeup": {:hex, :makeup, "1.0.3", "e339e2f766d12e7260e6672dd4047405963c5ec99661abdc432e6ec67d29ef95", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "2e9b4996d11832947731f7608fed7ad2f9443011b3b479ae288011265cdd3dad"}, 13 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"}, 14 | "metal": {:hex, :metal, "0.1.1", "5d3d1322da7bcd34b94fed5486f577973685298883954f7a3e517ef5ef6953f5", [:rebar3], [], "hexpm", "88b82b634998a1a768dedcd372c2f7e657b19445325c0af5ccbac62c77210f1d"}, 15 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 16 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"}, 17 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 18 | "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"}, 19 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, 20 | "plug": {:hex, :plug, "1.10.3", "c9cebe917637d8db0e759039cc106adca069874e1a9034fd6e3fdd427fd3c283", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "01f9037a2a1de1d633b5a881101e6a444bcabb1d386ca1e00bb273a1f1d9d939"}, 21 | "plug_cowboy": {:hex, :plug_cowboy, "2.3.0", "149a50e05cb73c12aad6506a371cd75750c0b19a32f81866e1a323dda9e0e99d", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bc595a1870cef13f9c1e03df56d96804db7f702175e4ccacdb8fc75c02a7b97e"}, 22 | "plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"}, 23 | "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"}, 24 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, 25 | "shackle": {:hex, :shackle, "0.5.4", "0234ba60d897ce8a3ccc5a9cdc09db015b7bddc1f8494d172ab767814f9da460", [:rebar3], [{:granderl, "0.1.5", [hex: :granderl, repo: "hexpm", optional: false]}, {:metal, "0.1.1", [hex: :metal, repo: "hexpm", optional: false]}], "hexpm", "ee23bb3ec1ad55d4177d88bc8a543f1dca9be5e1709e4986437c93feaac52b5b"}, 26 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 27 | "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, 28 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"}, 29 | } 30 | -------------------------------------------------------------------------------- /test/jsonrpc2/handler_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JSONRPC2.HandlerTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ExUnit.CaptureLog 5 | 6 | describe "examples from JSON-RPC 2.0 spec at http://www.jsonrpc.org/specification#examples" do 7 | test "rpc call with positional parameters" do 8 | assert_rpc_reply( 9 | JSONRPC2.SpecHandler, 10 | ~s({"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}), 11 | ~s({"jsonrpc": "2.0", "result": 19, "id": 1}) 12 | ) 13 | 14 | assert_rpc_reply( 15 | JSONRPC2.SpecHandler, 16 | ~s({"jsonrpc": "2.0", "method": "subtract", "params": [23, 42], "id": 2}), 17 | ~s({"jsonrpc": "2.0", "result": -19, "id": 2}) 18 | ) 19 | end 20 | 21 | test "rpc call with named parameters" do 22 | assert_rpc_reply( 23 | JSONRPC2.SpecHandler, 24 | ~s({"jsonrpc": "2.0", "method": "subtract", "params": {"subtrahend": 23, "minuend": 42}, "id": 3}), 25 | ~s({"jsonrpc": "2.0", "result": 19, "id": 3}) 26 | ) 27 | 28 | assert_rpc_reply( 29 | JSONRPC2.SpecHandler, 30 | ~s({"jsonrpc": "2.0", "method": "subtract", "params": {"minuend": 42, "subtrahend": 23}, "id": 4}), 31 | ~s({"jsonrpc": "2.0", "result": 19, "id": 4}) 32 | ) 33 | end 34 | 35 | test "a Notification" do 36 | assert_rpc_noreply( 37 | JSONRPC2.SpecHandler, 38 | ~s({"jsonrpc": "2.0", "method": "update", "params": [1,2,3,4,5]}) 39 | ) 40 | 41 | assert_rpc_noreply(JSONRPC2.SpecHandler, ~s({"jsonrpc": "2.0", "method": "foobar"})) 42 | end 43 | 44 | test "rpc call of non-existent method" do 45 | assert_rpc_reply( 46 | JSONRPC2.SpecHandler, 47 | ~s({"jsonrpc": "2.0", "method": "foobar", "id": "1"}), 48 | ~s({"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found", "data": {"method": "foobar", "params": []}}, "id": "1"}) 49 | ) 50 | end 51 | 52 | test "rpc call with invalid JSON" do 53 | assert_rpc_reply( 54 | JSONRPC2.SpecHandler, 55 | ~s({"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz]), 56 | ~s({"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": null}) 57 | ) 58 | end 59 | 60 | test "rpc call with invalid Request object" do 61 | assert_rpc_reply( 62 | JSONRPC2.SpecHandler, 63 | ~s({"jsonrpc": "2.0", "method": 1, "params": "bar"}), 64 | ~s({"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}) 65 | ) 66 | end 67 | 68 | test "rpc call Batch, invalid JSON" do 69 | assert_rpc_reply( 70 | JSONRPC2.SpecHandler, 71 | ~s([ 72 | {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"}, 73 | {"jsonrpc": "2.0", "method" 74 | ]), 75 | ~s({"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": null}) 76 | ) 77 | end 78 | 79 | test "rpc call with an empty Array" do 80 | assert_rpc_reply( 81 | JSONRPC2.SpecHandler, 82 | ~s([]), 83 | ~s({"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}) 84 | ) 85 | end 86 | 87 | test "rpc call with an invalid Batch (but not empty)" do 88 | assert_rpc_reply( 89 | JSONRPC2.SpecHandler, 90 | ~s([1]), 91 | ~s([ 92 | {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null} 93 | ]) 94 | ) 95 | end 96 | 97 | test "rpc call with invalid Batch" do 98 | assert_rpc_reply( 99 | JSONRPC2.SpecHandler, 100 | ~s([1,2,3]), 101 | ~s([ 102 | {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}, 103 | {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}, 104 | {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null} 105 | ]) 106 | ) 107 | end 108 | 109 | test "rpc call Batch" do 110 | assert_rpc_reply( 111 | JSONRPC2.SpecHandler, 112 | ~s([ 113 | {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"}, 114 | {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]}, 115 | {"jsonrpc": "2.0", "method": "subtract", "params": [42,23], "id": "2"}, 116 | {"foo": "boo"}, 117 | {"jsonrpc": "2.0", "method": "foo.get", "params": {"name": "myself"}, "id": "5"}, 118 | {"jsonrpc": "2.0", "method": "get_data", "id": "9"} 119 | ]), 120 | ~s([ 121 | {"jsonrpc": "2.0", "result": 7, "id": "1"}, 122 | {"jsonrpc": "2.0", "result": 19, "id": "2"}, 123 | {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}, 124 | {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found", "data": {"method": "foo.get", "params": {"name": "myself"}}}, "id": "5"}, 125 | {"jsonrpc": "2.0", "result": ["hello", 5], "id": "9"} 126 | ]) 127 | ) 128 | end 129 | 130 | test "rpc call Batch (all notifications)" do 131 | assert_rpc_noreply( 132 | JSONRPC2.SpecHandler, 133 | ~s([ 134 | {"jsonrpc": "2.0", "method": "notify_sum", "params": [1,2,4]}, 135 | {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]} 136 | ]) 137 | ) 138 | end 139 | end 140 | 141 | describe "internal tests" do 142 | test "rpc call exit/raise/throw produces internal error" do 143 | capture = 144 | capture_log(fn -> 145 | assert_rpc_reply( 146 | JSONRPC2.ErrorHandler, 147 | ~s([ 148 | {"jsonrpc": "2.0", "method": "exit", "id": "1"}, 149 | {"jsonrpc": "2.0", "method": "raise", "id": "2"}, 150 | {"jsonrpc": "2.0", "method": "throw", "id": "3"} 151 | ]), 152 | ~s([ 153 | {"jsonrpc": "2.0", "id": "1", "error": {"message": "Internal error", "code": -32603}}, 154 | {"jsonrpc": "2.0", "id": "2", "error": {"message": "Internal error", "code": -32603}}, 155 | {"jsonrpc": "2.0", "id": "3", "error": {"message": "Internal error", "code": -32603}} 156 | ]) 157 | ) 158 | end) 159 | 160 | assert capture =~ "[error] Error in handler JSONRPC2.ErrorHandler for method exit with params: []:" 161 | assert capture =~ "[error] Error in handler JSONRPC2.ErrorHandler for method raise with params: []:" 162 | assert capture =~ "[error] Error in handler JSONRPC2.ErrorHandler for method throw with params: []:" 163 | end 164 | 165 | test "rpc call with invalid response" do 166 | capture = 167 | capture_log(fn -> 168 | assert_rpc_reply( 169 | JSONRPC2.ErrorHandler, 170 | ~s({"jsonrpc": "2.0", "method": "bad_reply", "id": "1"}), 171 | ~s({"jsonrpc": "2.0", "error": {"code": -32603, "message": "Internal error"}, "id": null}) 172 | ) 173 | end) 174 | 175 | assert capture =~ "[info] Handler JSONRPC2.ErrorHandler returned invalid reply:" 176 | end 177 | 178 | test "throwable errors" do 179 | assert_rpc_reply( 180 | JSONRPC2.ErrorHandler, 181 | ~s({"jsonrpc": "2.0", "method": "method_not_found", "id": "1"}), 182 | ~s({"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "1"}) 183 | ) 184 | 185 | assert_rpc_reply( 186 | JSONRPC2.ErrorHandler, 187 | ~s({"jsonrpc": "2.0", "method": "invalid_params", "params": ["bad"], "id": "1"}), 188 | ~s({"jsonrpc": "2.0", "error": {"code": -32602, "message": "Invalid params", "data": ["bad"]}, "id": "1"}) 189 | ) 190 | 191 | assert_rpc_reply( 192 | JSONRPC2.ErrorHandler, 193 | ~s({"jsonrpc": "2.0", "method": "custom_error", "id": "1"}), 194 | ~s({"jsonrpc": "2.0", "error": {"code": 404, "message": "Custom not found error"}, "id": "1"}) 195 | ) 196 | 197 | assert_rpc_reply( 198 | JSONRPC2.ErrorHandler, 199 | ~s({"jsonrpc": "2.0", "method": "custom_error", "params": ["bad"], "id": "1"}), 200 | ~s({"jsonrpc": "2.0", "error": {"code": 404, "message": "Custom not found error", "data": ["bad"]}, "id": "1"}) 201 | ) 202 | end 203 | end 204 | 205 | describe "buggy handlers" do 206 | test "handler can raise legit FunctionClauseError, instead of returning :method_not_found" do 207 | capture = 208 | capture_log(fn -> 209 | assert_raise(FunctionClauseError, fn -> 210 | assert_rpc_noreply( 211 | JSONRPC2.BuggyHandler, 212 | ~s([ 213 | {"jsonrpc": "2.0", "method": "raise_function_clause_error", "id": "1"} 214 | ]) 215 | ) 216 | end) 217 | end) 218 | 219 | assert capture =~ 220 | "[error] Error in handler JSONRPC2.BuggyHandler for method raise_function_clause_error with params: []" 221 | end 222 | end 223 | 224 | defp assert_rpc_reply(handler, call, expected_reply) do 225 | assert {:reply, reply} = handler.handle(call) 226 | assert JSONRPC2.Serializers.Jiffy.decode(reply) == JSONRPC2.Serializers.Jiffy.decode(expected_reply) 227 | end 228 | 229 | defp assert_rpc_noreply(handler, call) do 230 | assert :noreply == handler.handle(call) 231 | end 232 | end 233 | -------------------------------------------------------------------------------- /test/jsonrpc2/http_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JSONRPC2.HTTPTest do 2 | use ExUnit.Case, async: true 3 | 4 | setup do 5 | port = :rand.uniform(65535 - 1025) + 1025 6 | {:ok, pid} = JSONRPC2.Servers.HTTP.http(JSONRPC2.SpecHandler, port: port) 7 | 8 | on_exit(fn -> 9 | ref = Process.monitor(pid) 10 | JSONRPC2.Servers.HTTP.shutdown(JSONRPC2.SpecHandler.HTTP) 11 | 12 | receive do 13 | {:DOWN, ^ref, :process, ^pid, :shutdown} -> :ok 14 | end 15 | end) 16 | 17 | {:ok, %{port: port}} 18 | end 19 | 20 | test "call", %{port: port} do 21 | assert JSONRPC2.Clients.HTTP.call("http://localhost:#{port}/", "subtract", [2, 1]) == {:ok, 1} 22 | end 23 | 24 | test "call with custom id", %{port: port} do 25 | expected = {:ok, 1} 26 | actual = JSONRPC2.Clients.HTTP.call("http://localhost:#{port}/", "subtract", [2, 1], [], :post, [], 123) 27 | assert actual == expected 28 | end 29 | 30 | test "notify", %{port: port} do 31 | assert JSONRPC2.Clients.HTTP.notify("http://localhost:#{port}/", "subtract", [2, 1]) == :ok 32 | end 33 | 34 | test "batch", %{port: port} do 35 | batch = [{"subtract", [2, 1]}, {"subtract", [2, 1], 0}, {"subtract", [2, 2], 1}] 36 | expected = [ok: {0, {:ok, 1}}, ok: {1, {:ok, 0}}] 37 | assert JSONRPC2.Clients.HTTP.batch("http://localhost:#{port}/", batch) == expected 38 | end 39 | 40 | test "call text/plain", %{port: port} do 41 | assert JSONRPC2.Clients.HTTP.call("http://localhost:#{port}/", "subtract", [2, 1], [ 42 | {"content-type", "text/plain"} 43 | ]) == {:ok, 1} 44 | end 45 | 46 | test "notify text/plain", %{port: port} do 47 | assert JSONRPC2.Clients.HTTP.notify("http://localhost:#{port}/", "subtract", [2, 1], [ 48 | {"content-type", "text/plain"} 49 | ]) == :ok 50 | end 51 | 52 | test "batch text/plain", %{port: port} do 53 | batch = [{"subtract", [2, 1]}, {"subtract", [2, 1], 0}, {"subtract", [2, 2], 1}] 54 | expected = [ok: {0, {:ok, 1}}, ok: {1, {:ok, 0}}] 55 | 56 | assert JSONRPC2.Clients.HTTP.batch("http://localhost:#{port}/", batch, [{"content-type", "text/plain"}]) == 57 | expected 58 | end 59 | 60 | test "bad call", %{port: port} do 61 | assert {:error, {:http_request_failed, 404, _headers, {:ok, ""}}} = 62 | JSONRPC2.Clients.HTTP.call( 63 | "http://localhost:#{port}/", 64 | "subtract", 65 | [2, 1], 66 | [ 67 | {"content-type", "application/json"} 68 | ], 69 | :get 70 | ) 71 | end 72 | 73 | test "call application/json", %{port: port} do 74 | assert JSONRPC2.Clients.HTTP.call( 75 | "http://localhost:#{port}/", 76 | "subtract", 77 | %{"minuend" => 2, "subtrahend" => 1}, 78 | [ 79 | {"content-type", "application/json"} 80 | ], 81 | :post 82 | ) == {:ok, 1} 83 | 84 | assert JSONRPC2.Clients.HTTP.call( 85 | "http://localhost:#{port}/", 86 | "subtract", 87 | %{minuend: 2, subtrahend: 1}, 88 | [ 89 | {"content-type", "application/json"} 90 | ], 91 | :post 92 | ) == {:ok, 1} 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test/jsonrpc2/tcp_test.exs: -------------------------------------------------------------------------------- 1 | for line_packet <- [false, true] do 2 | module_name = 3 | if line_packet do 4 | JSONRPC2.TCPTest.LineTerminated 5 | else 6 | JSONRPC2.TCPTest 7 | end 8 | 9 | defmodule module_name do 10 | use ExUnit.Case 11 | 12 | setup do 13 | port = :rand.uniform(65535 - 1025) + 1025 14 | 15 | {:ok, pid} = 16 | JSONRPC2.Servers.TCP.start_listener(JSONRPC2.SpecHandler, port, 17 | name: __MODULE__, 18 | line_packet: unquote(line_packet) 19 | ) 20 | 21 | :ok = JSONRPC2.Clients.TCP.start("localhost", port, __MODULE__, line_packet: unquote(line_packet)) 22 | 23 | on_exit(fn -> 24 | ref = Process.monitor(pid) 25 | JSONRPC2.Clients.TCP.stop(__MODULE__) 26 | JSONRPC2.Servers.TCP.stop(__MODULE__) 27 | 28 | receive do 29 | {:DOWN, ^ref, :process, ^pid, :shutdown} -> :ok 30 | end 31 | end) 32 | end 33 | 34 | test "call" do 35 | assert JSONRPC2.Clients.TCP.call(__MODULE__, "subtract", [2, 1]) == {:ok, 1} 36 | 37 | assert JSONRPC2.Clients.TCP.call(__MODULE__, "subtract", [2, 1], true) == {:ok, 1} 38 | 39 | assert JSONRPC2.Clients.TCP.call(__MODULE__, "subtract", [2, 1], string_id: true) == {:ok, 1} 40 | 41 | assert JSONRPC2.Clients.TCP.call(__MODULE__, "subtract", [2, 1], timeout: 2_000) == {:ok, 1} 42 | end 43 | 44 | test "cast" do 45 | {:ok, request_id} = JSONRPC2.Clients.TCP.cast(__MODULE__, "subtract", [2, 1], timeout: 1_000) 46 | assert JSONRPC2.Clients.TCP.receive_response(request_id) == {:ok, 1} 47 | 48 | {:ok, request_id} = JSONRPC2.Clients.TCP.cast(__MODULE__, "subtract", [2, 1], true) 49 | assert JSONRPC2.Clients.TCP.receive_response(request_id) == {:ok, 1} 50 | 51 | {:ok, request_id} = 52 | JSONRPC2.Clients.TCP.cast(__MODULE__, "subtract", [2, 1], string_id: true, timeout: 2_000) 53 | 54 | assert JSONRPC2.Clients.TCP.receive_response(request_id) == {:ok, 1} 55 | end 56 | 57 | test "notify" do 58 | {:ok, _request_id} = JSONRPC2.Clients.TCP.notify(__MODULE__, "subtract", [2, 1]) 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/support/handlers.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONRPC2.SpecHandler do 2 | use JSONRPC2.Server.Handler 3 | 4 | def handle_request("subtract", [x, y]) do 5 | x - y 6 | end 7 | 8 | def handle_request("subtract", %{"minuend" => x, "subtrahend" => y}) do 9 | x - y 10 | end 11 | 12 | def handle_request("update", _) do 13 | :ok 14 | end 15 | 16 | def handle_request("sum", numbers) do 17 | Enum.sum(numbers) 18 | end 19 | 20 | def handle_request("get_data", []) do 21 | ["hello", 5] 22 | end 23 | end 24 | 25 | defmodule JSONRPC2.ErrorHandler do 26 | use JSONRPC2.Server.Handler 27 | 28 | def handle_request("exit", []) do 29 | exit(:no_good) 30 | end 31 | 32 | def handle_request("raise", []) do 33 | raise "no good" 34 | end 35 | 36 | def handle_request("throw", []) do 37 | throw(:no_good) 38 | end 39 | 40 | def handle_request("bad_reply", []) do 41 | make_ref() 42 | end 43 | 44 | def handle_request("method_not_found", []) do 45 | throw(:method_not_found) 46 | end 47 | 48 | def handle_request("invalid_params", params) do 49 | throw({:invalid_params, params}) 50 | end 51 | 52 | def handle_request("custom_error", []) do 53 | throw({:jsonrpc2, 404, "Custom not found error"}) 54 | end 55 | 56 | def handle_request("custom_error", other) do 57 | throw({:jsonrpc2, 404, "Custom not found error", other}) 58 | end 59 | end 60 | 61 | defmodule JSONRPC2.BuggyHandler do 62 | use JSONRPC2.Server.Handler 63 | 64 | @dialyzer [:no_return, :no_opaque] 65 | 66 | def handle_request("raise_function_clause_error", []) do 67 | String.contains?(5, 5) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Enum.map([:jiffy, :ranch, :shackle, :hackney], &Application.ensure_all_started/1) 2 | ExUnit.start() 3 | --------------------------------------------------------------------------------