Skip to content

Commit

Permalink
Merge pull request #511 from chaodhib/add_messagepack_middleware
Browse files Browse the repository at this point in the history
Add a middleware to encode/decode MessagePack payloads
  • Loading branch information
yordis authored Dec 10, 2022
2 parents 5e983c1 + 5e8976e commit 7984a75
Show file tree
Hide file tree
Showing 4 changed files with 302 additions and 0 deletions.
162 changes: 162 additions & 0 deletions lib/tesla/middleware/message_pack.ex
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
3 changes: 3 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ defmodule Tesla.Mixfile do
{:poison, ">= 1.0.0", optional: true},
{:exjsx, ">= 3.0.0", optional: true},

# messagepack parsers
{:msgpax, "~> 2.3", optional: true},

# other
{:fuse, "~> 2.4", optional: true},
{:telemetry, "~> 0.4 or ~> 1.0", optional: true},
Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"mint": {:hex, :mint, "1.3.0", "396b3301102f7b775e103da5a20494b25753aed818d6d6f0ad222a3a018c3600", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "a9aac960562e43ca69a77e5176576abfa78b8398cec5543dd4fb4ab0131d5c1e"},
"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"},
"msgpax": {:hex, :msgpax, "2.3.1", "28e17c4abb4c57da742e75de62abd9d01c76f1da0b103334de3fb1199610b3d9", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "17c8bf2fc2327b74e4bc6633dd520ffa10ea07b0a2f8ab1932db99044e116df5"},
"nimble_options": {:hex, :nimble_options, "0.4.0", "c89babbab52221a24b8d1ff9e7d838be70f0d871be823165c94dd3418eea728f", [:mix], [], "hexpm", "e6701c1af326a11eea9634a3b1c62b475339ace9456c1a23ec3bc9a847bca02d"},
"nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"},
"nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"},
Expand Down
136 changes: 136 additions & 0 deletions test/tesla/middleware/message_pack_test.exs
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

0 comments on commit 7984a75

Please sign in to comment.