Skip to content

Commit 7984a75

Browse files
authored
Merge pull request #511 from chaodhib/add_messagepack_middleware
Add a middleware to encode/decode MessagePack payloads
2 parents 5e983c1 + 5e8976e commit 7984a75

4 files changed

Lines changed: 302 additions & 0 deletions

File tree

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

mix.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
3333
"mint": {:hex, :mint, "1.3.0", "396b3301102f7b775e103da5a20494b25753aed818d6d6f0ad222a3a018c3600", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "a9aac960562e43ca69a77e5176576abfa78b8398cec5543dd4fb4ab0131d5c1e"},
3434
"mix_test_watch": {:hex, :mix_test_watch, "1.0.3", "63d5b21e9278abf519f359e6d59aed704ed3c72ec38be6ab22306ae5dc9a2e06", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "7352e91952d9748fb4f8aebe0a60357cdaf4bd6d6c42b5139c78fbcda6a0d7a2"},
35+
"msgpax": {:hex, :msgpax, "2.3.1", "28e17c4abb4c57da742e75de62abd9d01c76f1da0b103334de3fb1199610b3d9", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "17c8bf2fc2327b74e4bc6633dd520ffa10ea07b0a2f8ab1932db99044e116df5"},
3536
"nimble_options": {:hex, :nimble_options, "0.4.0", "c89babbab52221a24b8d1ff9e7d838be70f0d871be823165c94dd3418eea728f", [:mix], [], "hexpm", "e6701c1af326a11eea9634a3b1c62b475339ace9456c1a23ec3bc9a847bca02d"},
3637
"nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"},
3738
"nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"},
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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, _}} =
83+
Client.get("/invalid-msgpack-format")
84+
end
85+
end
86+
87+
describe "Custom content type" do
88+
defmodule CustomContentTypeClient do
89+
use Tesla
90+
91+
plug Tesla.Middleware.MessagePack, decode_content_types: ["application/x-custom-msgpack"]
92+
93+
adapter fn env ->
94+
{status, headers, body} =
95+
case env.url do
96+
"/decode" ->
97+
{200, [{"content-type", "application/x-custom-msgpack"}],
98+
Msgpax.pack!(%{"value" => 123})}
99+
end
100+
101+
{:ok, %{env | status: status, headers: headers, body: body}}
102+
end
103+
end
104+
105+
test "decode if Content-Type specified in :decode_content_types" do
106+
assert {:ok, env} = CustomContentTypeClient.get("/decode")
107+
assert env.body == %{"value" => 123}
108+
end
109+
end
110+
111+
describe "EncodeMessagePack / DecodeMessagePack" do
112+
defmodule EncodeDecodeMessagePackClient do
113+
use Tesla
114+
115+
plug Tesla.Middleware.DecodeMessagePack
116+
plug Tesla.Middleware.EncodeMessagePack
117+
118+
adapter fn env ->
119+
{status, headers, body} =
120+
case env.url do
121+
"/foo2baz" ->
122+
{200, [{"content-type", "application/msgpack"}],
123+
env.body |> String.replace("foo", "baz")}
124+
end
125+
126+
{:ok, %{env | status: status, headers: headers, body: body}}
127+
end
128+
end
129+
130+
test "EncodeMessagePack / DecodeMessagePack work without options" do
131+
body = Msgpax.pack!(%{"foo" => "bar"}, iodata: false)
132+
assert {:ok, env} = EncodeDecodeMessagePackClient.post("/foo2baz", body)
133+
assert env.body == %{"baz" => "bar"}
134+
end
135+
end
136+
end

0 commit comments

Comments
 (0)