Skip to content

Commit b4f8c66

Browse files
committed
Add a middleware to encode/decode MessagePack payloads
1 parent 5e983c1 commit b4f8c66

3 files changed

Lines changed: 296 additions & 0 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
defmodule Tesla.Middleware.MessagePack do
2+
@moduledoc """
3+
Encode requests and decode responses as MessagePack.
4+
5+
This middleware requires [Msgpax](https://hex.pm/packages/msgpax) as dependency.
6+
7+
Remember to add `{:msgpax, ">= 2.3.0"}` to dependencies.
8+
Also, you need to recompile Tesla after adding `:msgpax` dependency:
9+
10+
```
11+
mix deps.clean tesla
12+
mix deps.compile tesla
13+
```
14+
15+
## Examples
16+
17+
```
18+
defmodule MyClient do
19+
use Tesla
20+
21+
plug Tesla.Middleware.MessagePack
22+
# or
23+
plug Tesla.Middleware.MessagePack, engine_opts: [binary: true]
24+
# or
25+
plug Tesla.Middleware.MessagePack, decode: &Custom.decode/1, encode: &Custom.encode/1
26+
end
27+
```
28+
29+
## Options
30+
31+
- `:decode` - decoding function
32+
- `:encode` - encoding function
33+
- `:encode_content_type` - content-type to be used in request header
34+
- `:decode_content_types` - list of additional decodable content-types
35+
- `:engine_opts` - optional engine options
36+
"""
37+
38+
@behaviour Tesla.Middleware
39+
40+
@content_type "application/msgpack"
41+
42+
@impl Tesla.Middleware
43+
def call(env, next, opts) do
44+
opts = opts || []
45+
46+
with {:ok, env} <- encode(env, opts),
47+
{:ok, env} <- Tesla.run(env, next) do
48+
decode(env, opts)
49+
end
50+
end
51+
52+
@doc """
53+
Encode request body as MessagePack.
54+
55+
It is used by `Tesla.Middleware.EncodeMessagePack`.
56+
"""
57+
def encode(env, opts) do
58+
with true <- encodable?(env),
59+
{:ok, body} <- encode_body(env.body, opts) do
60+
{:ok,
61+
env
62+
|> Tesla.put_body(body)
63+
|> Tesla.put_headers([{"content-type", encode_content_type(opts)}])}
64+
else
65+
false -> {:ok, env}
66+
error -> error
67+
end
68+
end
69+
70+
defp encode_body(body, opts), do: process(body, :encode, opts)
71+
72+
defp encode_content_type(opts),
73+
do: Keyword.get(opts, :encode_content_type, @content_type)
74+
75+
defp encodable?(%{body: nil}), do: false
76+
defp encodable?(%{body: body}) when is_binary(body), do: false
77+
defp encodable?(%{body: %Tesla.Multipart{}}), do: false
78+
defp encodable?(_), do: true
79+
80+
@doc """
81+
Decode response body as MessagePack.
82+
83+
It is used by `Tesla.Middleware.DecodeMessagePack`.
84+
"""
85+
def decode(env, opts) do
86+
with true <- decodable?(env, opts),
87+
{:ok, body} <- decode_body(env.body, opts) do
88+
{:ok, %{env | body: body}}
89+
else
90+
false -> {:ok, env}
91+
error -> error
92+
end
93+
end
94+
95+
defp decode_body(body, opts), do: process(body, :decode, opts)
96+
97+
defp decodable?(env, opts), do: decodable_body?(env) && decodable_content_type?(env, opts)
98+
99+
defp decodable_body?(env) do
100+
(is_binary(env.body) && env.body != "") || (is_list(env.body) && env.body != [])
101+
end
102+
103+
defp decodable_content_type?(env, opts) do
104+
case Tesla.get_header(env, "content-type") do
105+
nil -> false
106+
content_type -> Enum.any?(content_types(opts), &String.starts_with?(content_type, &1))
107+
end
108+
end
109+
110+
defp content_types(opts),
111+
do: [@content_type] ++ Keyword.get(opts, :decode_content_types, [])
112+
113+
defp process(data, op, opts) do
114+
case do_process(data, op, opts) do
115+
{:ok, data} -> {:ok, data}
116+
{:error, reason} -> {:error, {__MODULE__, op, reason}}
117+
{:error, reason, _pos} -> {:error, {__MODULE__, op, reason}}
118+
end
119+
rescue
120+
ex in Protocol.UndefinedError ->
121+
{:error, {__MODULE__, op, ex}}
122+
end
123+
124+
defp do_process(data, op, opts) do
125+
# :encode/:decode
126+
if fun = opts[op] do
127+
fun.(data)
128+
else
129+
opts = Keyword.get(opts, :engine_opts, [])
130+
131+
case op do
132+
:encode -> Msgpax.pack(data, opts)
133+
:decode -> Msgpax.unpack(data, opts)
134+
end
135+
end
136+
end
137+
end
138+
139+
defmodule Tesla.Middleware.DecodeMessagePack do
140+
@moduledoc false
141+
def call(env, next, opts) do
142+
opts = opts || []
143+
144+
with {:ok, env} <- Tesla.run(env, next) do
145+
Tesla.Middleware.MessagePack.decode(env, opts)
146+
end
147+
end
148+
end
149+
150+
defmodule Tesla.Middleware.EncodeMessagePack do
151+
@moduledoc false
152+
def call(env, next, opts) do
153+
opts = opts || []
154+
155+
with {:ok, env} <- Tesla.Middleware.MessagePack.encode(env, opts) do
156+
Tesla.run(env, next)
157+
end
158+
end
159+
end

mix.exs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ defmodule Tesla.Mixfile do
6767
{:poison, ">= 1.0.0", optional: true},
6868
{:exjsx, ">= 3.0.0", optional: true},
6969

70+
# messagepack parsers
71+
{:msgpax, "~> 2.3", optional: true},
72+
7073
# other
7174
{:fuse, "~> 2.4", optional: true},
7275
{:telemetry, "~> 0.4 or ~> 1.0", optional: true},
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
defmodule Tesla.Middleware.MessagePackTest do
2+
use ExUnit.Case
3+
4+
describe "Basics" do
5+
defmodule Client do
6+
use Tesla
7+
8+
plug Tesla.Middleware.MessagePack
9+
10+
adapter fn env ->
11+
{status, headers, body} =
12+
case env.url do
13+
"/decode" ->
14+
{200, [{"content-type", "application/msgpack"}], Msgpax.pack!(%{"value" => 123})}
15+
16+
"/encode" ->
17+
{200, [{"content-type", "application/msgpack"}],
18+
env.body |> String.replace("foo", "baz")}
19+
20+
"/empty" ->
21+
{200, [{"content-type", "application/msgpack"}], nil}
22+
23+
"/empty-string" ->
24+
{200, [{"content-type", "application/msgpack"}], ""}
25+
26+
"/invalid-content-type" ->
27+
{200, [{"content-type", "text/plain"}], "hello"}
28+
29+
"/invalid-msgpack-format" ->
30+
{200, [{"content-type", "application/msgpack"}], "{\"foo\": bar}"}
31+
32+
"/raw" ->
33+
{200, [], env.body}
34+
end
35+
36+
{:ok, %{env | status: status, headers: headers, body: body}}
37+
end
38+
end
39+
40+
test "decode MessagePack body" do
41+
assert {:ok, env} = Client.get("/decode")
42+
assert env.body == %{"value" => 123}
43+
end
44+
45+
test "encode body as MessagePack" do
46+
body = Msgpax.pack!(%{"foo" => "bar"}, iodata: false)
47+
assert {:ok, env} = Client.post("/encode", body)
48+
assert env.body == %{"baz" => "bar"}
49+
end
50+
51+
test "do not decode empty body" do
52+
assert {:ok, env} = Client.get("/empty")
53+
assert env.body == nil
54+
end
55+
56+
test "do not decode empty string body" do
57+
assert {:ok, env} = Client.get("/empty-string")
58+
assert env.body == ""
59+
end
60+
61+
test "decode only if Content-Type is application/msgpack" do
62+
assert {:ok, env} = Client.get("/invalid-content-type")
63+
assert env.body == "hello"
64+
end
65+
66+
test "do not encode nil body" do
67+
assert {:ok, env} = Client.post("/raw", nil)
68+
assert env.body == nil
69+
end
70+
71+
test "do not encode binary body" do
72+
assert {:ok, env} = Client.post("/raw", "raw-string")
73+
assert env.body == "raw-string"
74+
end
75+
76+
test "return error on encoding error" do
77+
assert {:error, {Tesla.Middleware.MessagePack, :encode, _}} =
78+
Client.post("/encode", %{pid: self()})
79+
end
80+
81+
test "return error when decoding invalid msgpack format" do
82+
assert {:error, {Tesla.Middleware.MessagePack, :decode, _}} = Client.get("/invalid-msgpack-format")
83+
end
84+
end
85+
86+
describe "Custom content type" do
87+
defmodule CustomContentTypeClient do
88+
use Tesla
89+
90+
plug Tesla.Middleware.MessagePack, decode_content_types: ["application/x-custom-msgpack"]
91+
92+
adapter fn env ->
93+
{status, headers, body} =
94+
case env.url do
95+
"/decode" ->
96+
{200, [{"content-type", "application/x-custom-msgpack"}], Msgpax.pack!(%{"value" => 123})}
97+
end
98+
99+
{:ok, %{env | status: status, headers: headers, body: body}}
100+
end
101+
end
102+
103+
test "decode if Content-Type specified in :decode_content_types" do
104+
assert {:ok, env} = CustomContentTypeClient.get("/decode")
105+
assert env.body == %{"value" => 123}
106+
end
107+
end
108+
109+
describe "EncodeMessagePack / DecodeMessagePack" do
110+
defmodule EncodeDecodeMessagePackClient do
111+
use Tesla
112+
113+
plug Tesla.Middleware.DecodeMessagePack
114+
plug Tesla.Middleware.EncodeMessagePack
115+
116+
adapter fn env ->
117+
{status, headers, body} =
118+
case env.url do
119+
"/foo2baz" ->
120+
{200, [{"content-type", "application/msgpack"}],
121+
env.body |> String.replace("foo", "baz")}
122+
end
123+
124+
{:ok, %{env | status: status, headers: headers, body: body}}
125+
end
126+
end
127+
128+
test "EncodeMessagePack / DecodeMessagePack work without options" do
129+
body = Msgpax.pack!(%{"foo" => "bar"}, iodata: false)
130+
assert {:ok, env} = EncodeDecodeMessagePackClient.post("/foo2baz", body)
131+
assert env.body == %{"baz" => "bar"}
132+
end
133+
end
134+
end

0 commit comments

Comments
 (0)