-
Notifications
You must be signed in to change notification settings - Fork 348
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #511 from chaodhib/add_messagepack_middleware
Add a middleware to encode/decode MessagePack payloads
- Loading branch information
Showing
4 changed files
with
302 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,162 @@ | ||
if Code.ensure_loaded?(Msgpax) do | ||
defmodule Tesla.Middleware.MessagePack do | ||
@moduledoc """ | ||
Encode requests and decode responses as MessagePack. | ||
This middleware requires [Msgpax](https://hex.pm/packages/msgpax) as dependency. | ||
Remember to add `{:msgpax, ">= 2.3.0"}` to dependencies. | ||
Also, you need to recompile Tesla after adding `:msgpax` dependency: | ||
``` | ||
mix deps.clean tesla | ||
mix deps.compile tesla | ||
``` | ||
## Examples | ||
``` | ||
defmodule MyClient do | ||
use Tesla | ||
plug Tesla.Middleware.MessagePack | ||
# or | ||
plug Tesla.Middleware.MessagePack, engine_opts: [binary: true] | ||
# or | ||
plug Tesla.Middleware.MessagePack, decode: &Custom.decode/1, encode: &Custom.encode/1 | ||
end | ||
``` | ||
## Options | ||
- `:decode` - decoding function | ||
- `:encode` - encoding function | ||
- `:encode_content_type` - content-type to be used in request header | ||
- `:decode_content_types` - list of additional decodable content-types | ||
- `:engine_opts` - optional engine options | ||
""" | ||
|
||
@behaviour Tesla.Middleware | ||
|
||
@default_decode_content_types ["application/msgpack", "application/x-msgpack"] | ||
@default_encode_content_type "application/msgpack" | ||
|
||
@impl Tesla.Middleware | ||
def call(env, next, opts) do | ||
opts = opts || [] | ||
|
||
with {:ok, env} <- encode(env, opts), | ||
{:ok, env} <- Tesla.run(env, next) do | ||
decode(env, opts) | ||
end | ||
end | ||
|
||
@doc """ | ||
Encode request body as MessagePack. | ||
It is used by `Tesla.Middleware.EncodeMessagePack`. | ||
""" | ||
def encode(env, opts) do | ||
with true <- encodable?(env), | ||
{:ok, body} <- encode_body(env.body, opts) do | ||
{:ok, | ||
env | ||
|> Tesla.put_body(body) | ||
|> Tesla.put_headers([{"content-type", encode_content_type(opts)}])} | ||
else | ||
false -> {:ok, env} | ||
error -> error | ||
end | ||
end | ||
|
||
defp encode_body(body, opts), do: process(body, :encode, opts) | ||
|
||
defp encode_content_type(opts), | ||
do: Keyword.get(opts, :encode_content_type, @default_encode_content_type) | ||
|
||
defp encodable?(%{body: nil}), do: false | ||
defp encodable?(%{body: body}) when is_binary(body), do: false | ||
defp encodable?(%{body: %Tesla.Multipart{}}), do: false | ||
defp encodable?(_), do: true | ||
|
||
@doc """ | ||
Decode response body as MessagePack. | ||
It is used by `Tesla.Middleware.DecodeMessagePack`. | ||
""" | ||
def decode(env, opts) do | ||
with true <- decodable?(env, opts), | ||
{:ok, body} <- decode_body(env.body, opts) do | ||
{:ok, %{env | body: body}} | ||
else | ||
false -> {:ok, env} | ||
error -> error | ||
end | ||
end | ||
|
||
defp decode_body(body, opts), do: process(body, :decode, opts) | ||
|
||
defp decodable?(env, opts), do: decodable_body?(env) && decodable_content_type?(env, opts) | ||
|
||
defp decodable_body?(env) do | ||
(is_binary(env.body) && env.body != "") || (is_list(env.body) && env.body != []) | ||
end | ||
|
||
defp decodable_content_type?(env, opts) do | ||
case Tesla.get_header(env, "content-type") do | ||
nil -> false | ||
content_type -> Enum.any?(content_types(opts), &String.starts_with?(content_type, &1)) | ||
end | ||
end | ||
|
||
defp content_types(opts), | ||
do: @default_decode_content_types ++ Keyword.get(opts, :decode_content_types, []) | ||
|
||
defp process(data, op, opts) do | ||
case do_process(data, op, opts) do | ||
{:ok, data} -> {:ok, data} | ||
{:error, reason} -> {:error, {__MODULE__, op, reason}} | ||
{:error, reason, _pos} -> {:error, {__MODULE__, op, reason}} | ||
end | ||
rescue | ||
ex in Protocol.UndefinedError -> | ||
{:error, {__MODULE__, op, ex}} | ||
end | ||
|
||
defp do_process(data, op, opts) do | ||
# :encode/:decode | ||
if fun = opts[op] do | ||
fun.(data) | ||
else | ||
opts = Keyword.get(opts, :engine_opts, []) | ||
|
||
case op do | ||
:encode -> Msgpax.pack(data, opts) | ||
:decode -> Msgpax.unpack(data, opts) | ||
end | ||
end | ||
end | ||
end | ||
|
||
defmodule Tesla.Middleware.DecodeMessagePack do | ||
@moduledoc false | ||
def call(env, next, opts) do | ||
opts = opts || [] | ||
|
||
with {:ok, env} <- Tesla.run(env, next) do | ||
Tesla.Middleware.MessagePack.decode(env, opts) | ||
end | ||
end | ||
end | ||
|
||
defmodule Tesla.Middleware.EncodeMessagePack do | ||
@moduledoc false | ||
def call(env, next, opts) do | ||
opts = opts || [] | ||
|
||
with {:ok, env} <- Tesla.Middleware.MessagePack.encode(env, opts) do | ||
Tesla.run(env, next) | ||
end | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
defmodule Tesla.Middleware.MessagePackTest do | ||
use ExUnit.Case | ||
|
||
describe "Basics" do | ||
defmodule Client do | ||
use Tesla | ||
|
||
plug Tesla.Middleware.MessagePack | ||
|
||
adapter fn env -> | ||
{status, headers, body} = | ||
case env.url do | ||
"/decode" -> | ||
{200, [{"content-type", "application/msgpack"}], Msgpax.pack!(%{"value" => 123})} | ||
|
||
"/encode" -> | ||
{200, [{"content-type", "application/msgpack"}], | ||
env.body |> String.replace("foo", "baz")} | ||
|
||
"/empty" -> | ||
{200, [{"content-type", "application/msgpack"}], nil} | ||
|
||
"/empty-string" -> | ||
{200, [{"content-type", "application/msgpack"}], ""} | ||
|
||
"/invalid-content-type" -> | ||
{200, [{"content-type", "text/plain"}], "hello"} | ||
|
||
"/invalid-msgpack-format" -> | ||
{200, [{"content-type", "application/msgpack"}], "{\"foo\": bar}"} | ||
|
||
"/raw" -> | ||
{200, [], env.body} | ||
end | ||
|
||
{:ok, %{env | status: status, headers: headers, body: body}} | ||
end | ||
end | ||
|
||
test "decode MessagePack body" do | ||
assert {:ok, env} = Client.get("/decode") | ||
assert env.body == %{"value" => 123} | ||
end | ||
|
||
test "encode body as MessagePack" do | ||
body = Msgpax.pack!(%{"foo" => "bar"}, iodata: false) | ||
assert {:ok, env} = Client.post("/encode", body) | ||
assert env.body == %{"baz" => "bar"} | ||
end | ||
|
||
test "do not decode empty body" do | ||
assert {:ok, env} = Client.get("/empty") | ||
assert env.body == nil | ||
end | ||
|
||
test "do not decode empty string body" do | ||
assert {:ok, env} = Client.get("/empty-string") | ||
assert env.body == "" | ||
end | ||
|
||
test "decode only if Content-Type is application/msgpack" do | ||
assert {:ok, env} = Client.get("/invalid-content-type") | ||
assert env.body == "hello" | ||
end | ||
|
||
test "do not encode nil body" do | ||
assert {:ok, env} = Client.post("/raw", nil) | ||
assert env.body == nil | ||
end | ||
|
||
test "do not encode binary body" do | ||
assert {:ok, env} = Client.post("/raw", "raw-string") | ||
assert env.body == "raw-string" | ||
end | ||
|
||
test "return error on encoding error" do | ||
assert {:error, {Tesla.Middleware.MessagePack, :encode, _}} = | ||
Client.post("/encode", %{pid: self()}) | ||
end | ||
|
||
test "return error when decoding invalid msgpack format" do | ||
assert {:error, {Tesla.Middleware.MessagePack, :decode, _}} = | ||
Client.get("/invalid-msgpack-format") | ||
end | ||
end | ||
|
||
describe "Custom content type" do | ||
defmodule CustomContentTypeClient do | ||
use Tesla | ||
|
||
plug Tesla.Middleware.MessagePack, decode_content_types: ["application/x-custom-msgpack"] | ||
|
||
adapter fn env -> | ||
{status, headers, body} = | ||
case env.url do | ||
"/decode" -> | ||
{200, [{"content-type", "application/x-custom-msgpack"}], | ||
Msgpax.pack!(%{"value" => 123})} | ||
end | ||
|
||
{:ok, %{env | status: status, headers: headers, body: body}} | ||
end | ||
end | ||
|
||
test "decode if Content-Type specified in :decode_content_types" do | ||
assert {:ok, env} = CustomContentTypeClient.get("/decode") | ||
assert env.body == %{"value" => 123} | ||
end | ||
end | ||
|
||
describe "EncodeMessagePack / DecodeMessagePack" do | ||
defmodule EncodeDecodeMessagePackClient do | ||
use Tesla | ||
|
||
plug Tesla.Middleware.DecodeMessagePack | ||
plug Tesla.Middleware.EncodeMessagePack | ||
|
||
adapter fn env -> | ||
{status, headers, body} = | ||
case env.url do | ||
"/foo2baz" -> | ||
{200, [{"content-type", "application/msgpack"}], | ||
env.body |> String.replace("foo", "baz")} | ||
end | ||
|
||
{:ok, %{env | status: status, headers: headers, body: body}} | ||
end | ||
end | ||
|
||
test "EncodeMessagePack / DecodeMessagePack work without options" do | ||
body = Msgpax.pack!(%{"foo" => "bar"}, iodata: false) | ||
assert {:ok, env} = EncodeDecodeMessagePackClient.post("/foo2baz", body) | ||
assert env.body == %{"baz" => "bar"} | ||
end | ||
end | ||
end |