@@ -146,18 +146,24 @@ defmodule Torque do
146146 {:ok, ~s({"id":"abc"})}
147147 """
148148 @ doc group: :encode
149- @ spec encode ( term ( ) , keyword ( ) ) :: { :ok , binary ( ) } | { :error , binary ( ) | :nesting_too_deep }
149+ @ spec encode ( term ( ) , keyword ( ) ) ::
150+ { :ok , binary ( ) }
151+ | { :error , binary ( ) | :nesting_too_deep | :unhandled_struct }
150152 def encode ( term , opts \\ [ ] )
151153
152154 def encode ( term , [ ] ) do
153- Torque.Native . encode ( term )
155+ case Torque.Native . encode ( term ) do
156+ { :error , :unhandled_struct } -> encode_retry ( term , false )
157+ other -> other
158+ end
154159 end
155160
156161 def encode ( term , opts ) do
157- if Keyword . get ( opts , :dirty , false ) do
158- Torque.Native . encode_dirty ( term )
159- else
160- Torque.Native . encode ( term )
162+ dirty = Keyword . get ( opts , :dirty , false )
163+
164+ case encode_native ( term , dirty ) do
165+ { :error , :unhandled_struct } -> encode_retry ( term , dirty )
166+ other -> other
161167 end
162168 end
163169
@@ -199,19 +205,23 @@ defmodule Torque do
199205 def encode_to_iodata ( term , opts \\ [ ] )
200206
201207 def encode_to_iodata ( term , [ ] ) do
202- Torque.Native . encode_iodata ( term )
208+ encode_iodata_native ( term , false )
203209 catch
204- :error , value -> raise ArgumentError , "encode error: #{ inspect ( value ) } "
210+ :error , :unhandled_struct ->
211+ term |> normalize ( ) |> encode_iodata_retry ( false )
212+
213+ :error , value ->
214+ raise ArgumentError , "encode error: #{ inspect ( value ) } "
205215 end
206216
207217 def encode_to_iodata ( term , opts ) do
208- if Keyword . get ( opts , :dirty , false ) do
209- Torque.Native . encode_iodata_dirty ( term )
210- else
211- Torque.Native . encode_iodata ( term )
212- end
218+ encode_iodata_native ( term , Keyword . get ( opts , :dirty , false ) )
213219 catch
214- :error , value -> raise ArgumentError , "encode error: #{ inspect ( value ) } "
220+ :error , :unhandled_struct ->
221+ term |> normalize ( ) |> encode_iodata_retry ( Keyword . get ( opts , :dirty , false ) )
222+
223+ :error , value ->
224+ raise ArgumentError , "encode error: #{ inspect ( value ) } "
215225 end
216226
217227 @ doc """
@@ -231,6 +241,137 @@ defmodule Torque do
231241 @ spec encode_to_iodata! ( term ( ) , keyword ( ) ) :: binary ( )
232242 def encode_to_iodata! ( term , opts \\ [ ] ) , do: encode_to_iodata ( term , opts )
233243
244+ # --- Encoding protocol ---
245+
246+ defprotocol Encoder do
247+ @ moduledoc """
248+ Optional protocol for encoding Elixir structs as JSON.
249+
250+ Torque's NIF encoder rejects structs (maps carrying an atom
251+ `__struct__` key) with `{:error, :unhandled_struct}`. When a struct
252+ implements this protocol, `encode/1` runs it first and encodes the
253+ returned term instead, recursively.
254+
255+ The protocol is deliberately opt-in: a struct without an
256+ implementation is an error, never silently dropped fields.
257+
258+ ## Deriving
259+
260+ Structs can derive the implementation, encoding all fields or a
261+ subset via `:only` / `:except`:
262+
263+ @derive {Torque.Encoder, only: [:id, :name]}
264+ defstruct [:id, :name, :secret]
265+
266+ @derive {Torque.Encoder, except: [:secret]}
267+ defstruct [:id, :name, :secret]
268+
269+ > Prefer `:only` to avoid accidentally leaking private information
270+ > when new fields are added later.
271+
272+ ## Example
273+
274+ defimpl Torque.Encoder, for: Decimal do
275+ def encode(decimal), do: Decimal.to_string(decimal)
276+ end
277+
278+ Torque.encode!(%{price: Decimal.new("37.50")})
279+ #=> ~s({"price":"37.50"})
280+ """
281+
282+ @ fallback_to_any true
283+
284+ @ spec encode ( term ( ) ) :: term ( )
285+ def encode ( term )
286+
287+ @ impl true
288+ defmacro __deriving__ ( module , opts ) do
289+ fields = module |> Macro . struct_info! ( __CALLER__ ) |> Enum . map ( & & 1 . field )
290+ fields = fields_to_encode ( fields , opts )
291+
292+ quote do
293+ defimpl Torque.Encoder , for: unquote ( module ) do
294+ def encode ( struct ) do
295+ Map . take ( struct , unquote ( fields ) )
296+ end
297+ end
298+ end
299+ end
300+
301+ defp fields_to_encode ( fields , opts ) do
302+ cond do
303+ only = Keyword . get ( opts , :only ) ->
304+ case only -- fields do
305+ [ ] ->
306+ only
307+
308+ error_keys ->
309+ raise ArgumentError ,
310+ "unknown struct fields #{ inspect ( error_keys ) } specified in :only. " <>
311+ "Expected one of: #{ inspect ( fields -- [ :__struct__ ] ) } "
312+ end
313+
314+ except = Keyword . get ( opts , :except ) ->
315+ case except -- fields do
316+ [ ] ->
317+ fields -- [ :__struct__ | except ]
318+
319+ error_keys ->
320+ raise ArgumentError ,
321+ "unknown struct fields #{ inspect ( error_keys ) } specified in :except. " <>
322+ "Expected one of: #{ inspect ( fields -- [ :__struct__ ] ) } "
323+ end
324+
325+ true ->
326+ fields -- [ :__struct__ ]
327+ end
328+ end
329+ end
330+
331+ # The Any fallback passes structs through untouched, so a struct without
332+ # an explicit implementation still fails in the NIF with
333+ # `:unhandled_struct` after the retry — it is never silently dropped.
334+ defimpl Encoder , for: Any do
335+ def encode ( term ) , do: term
336+ end
337+
338+ defp encode_native ( term , dirty ) do
339+ if dirty , do: Torque.Native . encode_dirty ( term ) , else: Torque.Native . encode ( term )
340+ end
341+
342+ defp encode_retry ( term , dirty ) do
343+ term |> normalize ( ) |> encode_native ( dirty )
344+ end
345+
346+ defp encode_iodata_native ( term , true ) , do: Torque.Native . encode_iodata_dirty ( term )
347+ defp encode_iodata_native ( term , false ) , do: Torque.Native . encode_iodata ( term )
348+
349+ # Retry path: a struct that still has no protocol implementation fails
350+ # with the same ArgumentError as every other encode error.
351+ defp encode_iodata_retry ( term , dirty ) do
352+ encode_iodata_native ( term , dirty )
353+ catch
354+ :error , value -> raise ArgumentError , "encode error: #{ inspect ( value ) } "
355+ end
356+
357+ defp normalize ( % _ { } = struct ) do
358+ if Encoder . impl_for ( struct ) != Encoder.Any do
359+ normalize ( Encoder . encode ( struct ) )
360+ else
361+ struct
362+ end
363+ end
364+
365+ defp normalize ( list ) when is_list ( list ) do
366+ Enum . map ( list , & normalize / 1 )
367+ end
368+
369+ defp normalize ( map ) when is_map ( map ) do
370+ Map . new ( map , fn { k , v } -> { k , normalize ( v ) } end )
371+ end
372+
373+ defp normalize ( term ) , do: term
374+
234375 # --- Parse + Get ---
235376
236377 @ doc """
0 commit comments