Skip to content

Commit 52d2381

Browse files
authored
Add support for custom struct serialization (#6)
* Define Encodable protocol * Add function clause to encoder for handling custom structs * Add tests for protocol and cleanup function clause in encoder * Update lib version in README * Update documentation
1 parent 8935276 commit 52d2381

8 files changed

Lines changed: 181 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
- Added a `:deterministic` option to `Msgpack.encode/2`
2121
- You can set this to `false` to disable key sorting for higher performance in
2222
contexts where deterministic output is not required.
23+
- Added the `Msgpack.Encodable` protocol to allow for custom serialization logic
24+
for any Elixir struct
25+
- This allows users to encode their own data types, such as %Product{} or
26+
%User{}, directly
2327

2428
## [v1.1.1] - 2025-08-09
2529

README.md

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ types.
1212
## Features
1313

1414
- **Specification Compliance:** Implements the complete MessagePack type system.
15-
- **Elixir Struct Support:** Encodes and decodes `DateTime` and `NaiveDateTime`
16-
structs via the Timestamp extension type.
15+
- **Extensible Struct Support:**
16+
- Natively encodes and decodes `DateTime` and `NaiveDateTime` structs via the
17+
Timestamp extension type.
18+
- Allows any custom struct to be encoded via the `Msgpack.Encodable` protocol.
1719
- **Configurable Validation:** Provides an option to bypass UTF-8 validation on
1820
strings for performance-critical paths.
1921
- **Resource Limiting:** Includes configurable `:max_depth` and `:max_byte_size`
@@ -30,7 +32,7 @@ Add `msgpack_elixir` to your list of dependencies in `mix.exs`:
3032

3133
```elixir
3234
def deps do
33-
[{:msgpack_elixir, "~> 1.0.0"}]
35+
[{:msgpack_elixir, "~> 2.0.0"}]
3436
end
3537
```
3638

@@ -104,6 +106,38 @@ determinism is not required, you can disable it:
104106
Msgpack.encode(map, deterministic: false)
105107
```
106108

109+
### Custom Struct Serialization
110+
111+
You can add custom encoding logic for your own Elixir structs by implementing
112+
the `Msgpack.Encodable` protocol. This allows `Msgpack.encode/2` to accept your
113+
structs directly, centralizing conversion logic within the protocol
114+
implementation.
115+
116+
117+
```elixir
118+
# 1. Define your application's struct
119+
defmodule Product do
120+
defstruct [:id, :name]
121+
end
122+
123+
# 2. Implement the `Msgpack.Encodable` protocol for that struct
124+
defimpl Msgpack.Encodable, for: Product do
125+
126+
# 3. Inside the protocol's `encode/1` function, transform your struct into a basic
127+
# Elixir term that MessagePack can encode (e.g., a map or a list).
128+
def encode(%Product{id: id, name: name}) do
129+
{:ok, %{"id" => id, "name" => name}}
130+
end
131+
end
132+
133+
iex> product = %Product{id: 1, name: "Elixir"}
134+
iex> {:ok, binary} = Msgpack.encode(product)
135+
<<130, 162, 105, 100, 1, 164, 110, 97, 109, 101, 166, 69, 108, 105, 120, 105, 114>>
136+
137+
iex> Msgpack.decode(binary)
138+
{:ok, %{"id" => 1, "name" => "Elixir"}}
139+
```
140+
107141
## Full Documentation
108142

109143
For detailed information on all features, options, and functions, see the [full

lib/msgpack.ex

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ defmodule Msgpack do
2929
to limit resource allocation when decoding.
3030
- **Telemetry Integration:** Emits `:telemetry` events for monitoring and
3131
observability.
32+
- **Extensible Structs:** Allows any custom Elixir struct to be encoded by
33+
implementing the `Msgpack.Encodable` protocol.
3234
3335
## Options
3436
@@ -93,6 +95,33 @@ defmodule Msgpack do
9395
* `false` - Disables key sorting, which can provide a performance gain in
9496
cases where determinism is not required.
9597
98+
## Custom Struct Support
99+
100+
This function can encode any custom Elixir struct that implements the
101+
`Msgpack.Encodable` protocol. This allows you to define custom serialization
102+
logic for your application structs.
103+
104+
For example, given a `Product` struct:
105+
106+
```elixir
107+
# 1. Define your struct
108+
defmodule Product do
109+
defstruct [:id, :name]
110+
end
111+
112+
# 2. Implement the protocol
113+
defimpl Msgpack.Encodable, for: Product do
114+
def encode(%Product{id: id, name: name}) do
115+
# Transform the struct into an encodable term (e.g., a map)
116+
{:ok, %{"id" => id, "name" => name}}
117+
end
118+
end
119+
120+
iex> product = %Product{id: 1, name: "Elixir"}
121+
iex> {:ok, binary} = Msgpack.encode(product)
122+
<<130, 162, 105, 100, 1, 164, 110, 97, 109, 101, 166, 69, 108, 105, 120, 105, 114>>
123+
```
124+
96125
## Examples
97126
98127
### Standard Encoding

lib/msgpack/encodable.ex

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
defprotocol Msgpack.Encodable do
2+
@moduledoc """
3+
A protocol for converting custom Elixir structs into a Msgpack-encodable
4+
format.
5+
6+
This protocol provides a hook into the `Msgpack.encode/2` function, allowing
7+
developers to define custom serialization logic for their structs.
8+
9+
## Contract
10+
11+
An implementation of `encode/1` for a struct must return a basic Elixir term
12+
that the Msgpack library can encode directly. This includes:
13+
- Maps (with string, integer, or atom keys that will be converted to strings)
14+
- Lists
15+
- Strings or Binaries
16+
- Integers
17+
- Floats
18+
- Booleans
19+
- `nil`
20+
21+
It is important that the returned term **must not** contain other custom
22+
structs that themselves require an `Encodable` implementation. The purpose of
23+
this protocol is to perform a single-level transformation from a custom struct
24+
into a directly encodable term. Returning a nested custom struct will result
25+
in an `{:error, {:unsupported_type, term}}` during encoding.
26+
27+
## Example
28+
29+
```elixir
30+
defimpl Msgpack.Encodable, for: User do
31+
def encode(%User{id: id, name: name}) do
32+
# Transform the User struct into a map, which is directly encodable.
33+
{:ok, %{"id" => id, "name" => name}}
34+
end
35+
end
36+
```
37+
"""
38+
39+
@doc """
40+
Receives a custom struct and must return `{:ok, term}` or `{:error, reason}`.
41+
42+
The `term` in a successful result must be a directly encodable Elixir type.
43+
"""
44+
@spec encode(struct()) :: {:ok, term()} | {:error, any()}
45+
def encode(struct)
46+
end

lib/msgpack/encoder.ex

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ defmodule Msgpack.Encoder do
33
Handles the logic of encoding Elixir terms into iodata.
44
"""
55

6+
alias Msgpack.Encodable
7+
68
@spec encode(term(), keyword()) :: {:ok, iodata()} | {:error, term()}
79
def encode(term, opts \\ []) do
810
merged_opts = Keyword.merge(default_opts(), opts)
@@ -130,6 +132,20 @@ defmodule Msgpack.Encoder do
130132
{:ok, [header, data]}
131133
end
132134

135+
# ==== Structs (Custom via Protocol) ====
136+
defp do_encode(%_{} = struct, opts) do
137+
with true <- Keyword.get(opts, :protocol_dispatch_enabled, true),
138+
{:ok, term} <- try_protocol_encode(struct) do
139+
do_encode(term, Keyword.put(opts, :protocol_dispatch_enabled, false))
140+
else
141+
false ->
142+
{:error, {:unsupported_type, struct.__struct__}}
143+
144+
{:error, reason} ->
145+
{:error, reason}
146+
end
147+
end
148+
133149
# ==== Lists ====
134150
defp do_encode(list, opts) when is_list(list) do
135151
acc = {:ok, []}
@@ -234,4 +250,11 @@ defmodule Msgpack.Encoder do
234250
[<<0xC7, 12, -1::signed-8>>, <<nanoseconds::32, seconds::signed-64>>]
235251
end
236252
end
253+
254+
defp try_protocol_encode(struct) do
255+
Encodable.encode(struct)
256+
rescue
257+
e in [Protocol.UndefinedError] ->
258+
{:error, {:unsupported_type, e.value.__struct__}}
259+
end
237260
end

mix.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ defmodule MsgpackElixir.MixProject do
2525
]
2626
end
2727

28-
defp elixirc_paths(_env), do: ["lib"]
28+
defp elixirc_paths(:test), do: ["lib", "test/support"]
29+
defp elixirc_paths(_), do: ["lib"]
2930

3031
defp deps do
3132
[

test/encodable_test.exs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
defmodule Msgpack.EncodableTest do
2+
use ExUnit.Case, async: true
3+
4+
alias Msgpack.EncodableTest.User
5+
alias Msgpack.EncodableTest.Product
6+
alias Msgpack
7+
8+
test "successfully encodes a custom struct with a protocol implementation" do
9+
user = %User{id: 1, name: "Bob"}
10+
expected_binary = <<0x82, 0xA2, "id", 1, 0xA4, "name", 0xA3, "Bob">>
11+
12+
assert Msgpack.encode(user) == {:ok, expected_binary}
13+
end
14+
15+
test "returns an error when encoding a struct without a protocol implementation" do
16+
product = %Product{id: 1234}
17+
expected_error = {:error, {:unsupported_type, Product}}
18+
19+
assert Msgpack.encode(product) == expected_error
20+
end
21+
end

test/support/encodable_structs.ex

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
defmodule Msgpack.EncodableTest.User do
2+
@moduledoc """
3+
A simple struct used for testing protocol implementations.
4+
"""
5+
defstruct [:id, :name]
6+
end
7+
8+
defmodule Msgpack.EncodableTest.Product do
9+
@moduledoc """
10+
A simple struct with no protocol implementation.
11+
"""
12+
defstruct [:id]
13+
end
14+
15+
defimpl Msgpack.Encodable, for: Msgpack.EncodableTest.User do
16+
def encode(%Msgpack.EncodableTest.User{id: id, name: name}) do
17+
{:ok, %{"id" => id, "name" => name}}
18+
end
19+
end

0 commit comments

Comments
 (0)