Skip to content

Commit f06757c

Browse files
author
andrei.lepeshkin
committed
Add first implementation
1 parent fc56434 commit f06757c

14 files changed

+325
-1
lines changed

.formatter.exs

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Used by "mix format"
2+
[
3+
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
4+
]

.gitignore

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# The directory Mix will write compiled artifacts to.
2+
/_build/
3+
4+
# If you run "mix test --cover", coverage assets end up here.
5+
/cover/
6+
7+
# The directory Mix downloads your dependencies sources to.
8+
/deps/
9+
10+
# Where third-party dependencies like ExDoc output generated docs.
11+
/doc/
12+
13+
# Ignore .fetch files in case you like to edit your project deps locally.
14+
/.fetch
15+
16+
# If the VM crashes, it generates a dump, let's ignore it too.
17+
erl_crash.dump
18+
19+
# Also ignore archive artifacts (built via "mix archive.build").
20+
*.ez
21+
22+
# Ignore package tarball (built via "mix hex.build").
23+
jsonrpc2_plug-*.tar
24+
25+
26+
# Temporary files for e.g. tests
27+
/tmp

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# JSONRPC2Plug
22

3-
JSONRPC 2 plug
3+
JSON-RPC 2 plug
44

55
## Installation
66

lib/jsonrpc2_plug.ex

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
defmodule JSONRPC2Plug do
2+
def init(handler),
3+
do: handler
4+
5+
@doc false
6+
def call(%{method: "POST", body_params: %Plug.Conn.Unfetched{}}, _handler),
7+
do: raise "Plug the JSONRPC2Plug after Plug.Parsers"
8+
9+
def call(%{method: "POST", body_params: body_params} = conn, handler) do
10+
resp_body = case handler.handle(body_params, conn) do
11+
[] -> ""
12+
nil -> ""
13+
resp -> Poison.encode!(resp)
14+
end
15+
16+
conn
17+
|> Plug.Conn.put_resp_header("content-type", "application/json")
18+
|> Plug.Conn.resp(200, resp_body)
19+
end
20+
21+
def call(conn, _),
22+
do: Plug.Conn.resp(conn, 404, "")
23+
end

lib/jsonrpc2_plug/error.ex

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
defmodule JSONRPC2Plug.Error do
2+
defstruct [:id, :error, jsonrpc: "2.0"]
3+
4+
@errors [
5+
parse_error: {-32700, "Parse error"},
6+
invalid_request: {-32600, "Invalid Request"},
7+
method_not_found: {-32601, "Method not found"},
8+
invalid_params: {-32602, "Invalid params"},
9+
internal_error: {-32603, "Internal error"},
10+
server_error: {-32000, "Server error"}
11+
]
12+
13+
def new(id, code, message),
14+
do: %__MODULE__{id: id, error: %{code: code, message: message}}
15+
def new(id, code, message, data),
16+
do: %__MODULE__{id: id, error: %{code: code, message: message, data: data}}
17+
18+
def error(type),
19+
do: error(type, nil)
20+
def error(type, id) do
21+
case Keyword.get(@errors, type) do
22+
{code, message} ->
23+
new(id, code, message)
24+
25+
nil ->
26+
new(id, -32000, "Internal error")
27+
end
28+
end
29+
end

lib/jsonrpc2_plug/handler.ex

+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
defmodule JSONRPC2Plug.Handler do
2+
require Logger
3+
4+
alias JSONRPC2Plug.Error
5+
alias JSONRPC2Plug.Request
6+
alias JSONRPC2Plug.Response
7+
8+
defmacro __using__(_) do
9+
quote do
10+
def handle(params, conn) do
11+
unquote(__MODULE__).handle(__MODULE__, params, conn)
12+
end
13+
14+
def handle_notification(method, params, conn),
15+
do: handle_request(method, params, conn)
16+
17+
def handle_request(_method, _params, _conn),
18+
do: error!(-32601, "Method not found")
19+
20+
def handle_error(exception, stacktrace),
21+
do: Logger.error(Exception.format(:error, exception, stacktrace))
22+
23+
defoverridable [handle_request: 3, handle_error: 2, handle_notification: 3]
24+
25+
defp error!(code, message),
26+
do: throw {:jsonrpc2, code, message}
27+
defp error!(code, message, data),
28+
do: throw {:jsonrpc2, code, message, data}
29+
end
30+
end
31+
32+
@doc false
33+
def handle(module, data, conn) when is_list(data),
34+
do: data |> Request.parse() |> handle_batch(module, conn)
35+
def handle(module, data, conn),
36+
do: data |> Request.parse() |> handle_one(module, conn)
37+
38+
defp handle_batch(data, module, conn),
39+
do: Enum.map(data, fn(tuple) -> handle_one(tuple, module, conn) end) |> Enum.reject(&is_nil/1)
40+
41+
defp handle_one(%Error{} = error, _module, _conn),
42+
do: error
43+
defp handle_one(%Request{} = request, module, conn) do
44+
if Request.valid?(request) do
45+
dispatch(module, request, conn)
46+
else
47+
Error.error(:invalid_request, request.id)
48+
end
49+
end
50+
51+
defp dispatch(module, %{id: nil, method: method, params: params}, conn) do
52+
try do
53+
module.handle_notification(method, params, conn)
54+
nil
55+
rescue
56+
ex ->
57+
module.handle_error(ex, __STACKTRACE__)
58+
catch
59+
:throw, {:jsonrpc2, code, message} when is_integer(code) and is_binary(message) ->
60+
nil
61+
62+
:throw, {:jsonrpc2, code, message, data} when is_integer(code) and is_binary(message) ->
63+
nil
64+
65+
kind, payload ->
66+
Logger.error([
67+
"Error in handler ", inspect(module), " for method ", method, " with params: ",
68+
inspect(params), ":\n\n", Exception.format(kind, payload, __STACKTRACE__)
69+
])
70+
71+
nil
72+
end
73+
end
74+
defp dispatch(module, %{id: id, method: method, params: params}, conn) do
75+
try do
76+
method
77+
|> module.handle_request(params, conn)
78+
|> Response.new(id)
79+
rescue
80+
ex ->
81+
module.handle_error(ex, __STACKTRACE__)
82+
catch
83+
:throw, {:jsonrpc2, code, message} when is_integer(code) and is_binary(message) ->
84+
Error.new(id, code, message)
85+
86+
:throw, {:jsonrpc2, code, message, data} when is_integer(code) and is_binary(message) ->
87+
Error.new(id, code, message, data)
88+
89+
kind, payload ->
90+
Logger.error([
91+
"Error in handler ", inspect(module), " for method ", method, " with params: ",
92+
inspect(params), ":\n\n", Exception.format(kind, payload, __STACKTRACE__)
93+
])
94+
95+
Error.error(:internal_error, id)
96+
end
97+
end
98+
99+
defp dispatch(_module, _data, _conn),
100+
do: Error.error(:invalid_request, nil)
101+
end

lib/jsonrpc2_plug/request.ex

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
defmodule JSONRPC2Plug.Request do
2+
defstruct [:id, :method, :params]
3+
4+
alias JSONRPC2Plug.Error
5+
6+
def parse([]),
7+
do: Error.error(:invalid_request)
8+
def parse(data) when is_list(data),
9+
do: Enum.map(data, &parse_one/1)
10+
def parse(data),
11+
do: parse_one(data)
12+
13+
def valid?(%__MODULE__{id: _, method: method, params: params}),
14+
do: is_binary(method) && (is_list(params) || is_map(params))
15+
16+
defp parse_one(%{"id" => id, "method" => method, "params" => params, "jsonrpc" => "2.0"}),
17+
do: %__MODULE__{id: id, method: method, params: params}
18+
defp parse_one(%{"id" => id}),
19+
do: Error.error(:invalid_request, id)
20+
defp parse_one(_),
21+
do: Error.error(:invalid_request)
22+
end

lib/jsonrpc2_plug/response.ex

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
defmodule JSONRPC2Plug.Response do
2+
defstruct [:id, :result, jsonrpc: "2.0"]
3+
4+
def new(id, result),
5+
do: %__MODULE__{id: id, result: result}
6+
end

mix.exs

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
defmodule Jsonrpc2Plug.MixProject do
2+
use Mix.Project
3+
4+
def project do
5+
[
6+
app: :jsonrpc2_plug,
7+
version: "0.1.0",
8+
elixir: "~> 1.11",
9+
start_permanent: Mix.env() == :prod,
10+
deps: deps()
11+
]
12+
end
13+
14+
# Run "mix help compile.app" to learn about applications.
15+
def application do
16+
[
17+
extra_applications: [:logger]
18+
]
19+
end
20+
21+
# Run "mix help deps" to learn about dependencies.
22+
defp deps do
23+
[
24+
{:poison, "~> 4.0"},
25+
{:plug, "~> 1.12"},
26+
{:dialyxir, "~> 0.3", only: :dev}
27+
]
28+
end
29+
end

mix.lock

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
%{
2+
"dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm", "6c32a70ed5d452c6650916555b1f96c79af5fc4bf286997f8b15f213de786f73"},
3+
"mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"},
4+
"plug": {:hex, :plug, "1.12.1", "645678c800601d8d9f27ad1aebba1fdb9ce5b2623ddb961a074da0b96c35187d", [:mix], [{:mime, "~> 1.0 or ~> 2.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.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d57e799a777bc20494b784966dc5fbda91eb4a09f571f76545b72a634ce0d30b"},
5+
"plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"},
6+
"poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"},
7+
"telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"},
8+
}

test/jsonrpc2_plug/error_test.exs

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
defmodule JSONRPC2Plug.ErrorTest do
2+
use ExUnit.Case
3+
4+
alias JSONRPC2Plug.Error
5+
6+
describe ".error" do
7+
test "Parse error" do
8+
assert %Error{id: 123, error: %{code: -32700, message: "Parse error"}} = Error.error(:parse_error, 123)
9+
assert %Error{id: nil, error: %{code: -32700, message: "Parse error"}} = Error.error(:parse_error)
10+
end
11+
12+
test "Invalid Request" do
13+
assert %Error{id: 123, error: %{code: -32600, message: "Invalid Request"}} = Error.error(:invalid_request, 123)
14+
assert %Error{id: nil, error: %{code: -32600, message: "Invalid Request"}} = Error.error(:invalid_request)
15+
end
16+
17+
test "Method not found" do
18+
assert %Error{id: 123, error: %{code: -32601, message: "Method not found"}} = Error.error(:method_not_found, 123)
19+
assert %Error{id: nil, error: %{code: -32601, message: "Method not found"}} = Error.error(:method_not_found)
20+
end
21+
22+
test "Invalid params" do
23+
assert %Error{id: 123, error: %{code: -32602, message: "Invalid params"}} = Error.error(:invalid_params, 123)
24+
assert %Error{id: nil, error: %{code: -32602, message: "Invalid params"}} = Error.error(:invalid_params)
25+
end
26+
27+
test "Internal error" do
28+
assert %Error{id: 123, error: %{code: -32603, message: "Internal error"}} = Error.error(:internal_error, 123)
29+
assert %Error{id: nil, error: %{code: -32603, message: "Internal error"}} = Error.error(:internal_error)
30+
end
31+
32+
test "Server error" do
33+
assert %Error{id: 123, error: %{code: -32000, message: "Server error"}} = Error.error(:server_error, 123)
34+
assert %Error{id: nil, error: %{code: -32000, message: "Server error"}} = Error.error(:server_error)
35+
end
36+
end
37+
end

test/jsonrpc2_plug/request_test.exs

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
defmodule JSONRPC2Plug.RequestTest do
2+
use ExUnit.Case
3+
4+
alias JSONRPC2Plug.Error
5+
alias JSONRPC2Plug.Request
6+
7+
describe ".parse" do
8+
test "batch" do
9+
assert %Error{id: nil, error: %{code: -32600, message: "Invalid Request"}} = Request.parse([])
10+
11+
example = [
12+
%Error{id: 123, error: %{code: -32600, message: "Invalid Request"}},
13+
%Request{id: 123, method: "test.method", params: [1, 2]}
14+
]
15+
16+
assert ^example = Request.parse([
17+
%{"id" => 123},
18+
%{"id" => 123, "method" => "test.method", "params" => [1, 2], "jsonrpc" => "2.0"}
19+
])
20+
end
21+
22+
test "one request" do
23+
assert %Error{id: nil, error: %{code: -32600, message: "Invalid Request"}} = Request.parse(%{})
24+
assert %Error{id: 123, error: %{code: -32600, message: "Invalid Request"}} = Request.parse(%{"id" => 123})
25+
assert %Request{id: 123, method: "test.method", params: [1, 2]} = Request.parse(%{
26+
"id" => 123, "method" => "test.method", "params" => [1, 2], "jsonrpc" => "2.0"
27+
})
28+
assert %Request{id: 123, method: "test.method", params: %{"k1" => "v1"}} = Request.parse(%{
29+
"id" => 123, "method" => "test.method", "params" => %{"k1" => "v1"}, "jsonrpc" => "2.0"
30+
})
31+
end
32+
end
33+
end

test/jsonrpc2_plug_test.exs

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
defmodule JSONRPC2PlugTest do
2+
use ExUnit.Case
3+
doctest JSONRPC2Plug
4+
end

test/test_helper.exs

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ExUnit.start()

0 commit comments

Comments
 (0)