How to have custom enconding for struct using Jason?

3.2k views Asked by At

Background

I am trying to encode a structure into json format using the Jason library. However, this is not working as expected.

Code

Let's assume I have this struct:

defmodule Test do
   defstruct [:foo, :bar, :baz]
end

And that when using Jason.enconde(%Test{foo: 1, bar: 2, baz:3 }) I want this json to be created:

%{"foo" => 1, "banana" => 5}

Error

It is my understanding that to achieve this I need to implement the Jason.Enconder protocol in my struct: https://hexdocs.pm/jason/Jason.Encoder.html

defmodule Test do
   defstruct [:foo, :bar, :baz]
   
   defimpl Jason.Encoder do
      @impl Jason.Encoder 
      def encode(value, opts) do
         Jason.Encode.map(%{foo: Map.get(value, :foo), banana: Map.get(value, :bar, 0) + Map.get(value, :baz, 0)}, opts)
      end
   end
end

However, this will not work:

Jason.encode(%Test{foo: 1, bar: 2, baz: 3})
{:error,
 %Protocol.UndefinedError{
   description: "Jason.Encoder protocol must always be explicitly implemented.\n\nIf you own the struct, you can derive the implementation specifying which fields should be encoded to JSON:\n\n    @derive {Jason.Encoder, only: [....]}\n    defstruct ...\n\nIt is also possible to encode all fields, although this should be used carefully to avoid accidentally leaking private information when new fields are added:\n\n    @derive Jason.Encoder\n    defstruct ...\n\nFinally, if you don't own the struct you want to encode to JSON, you may use Protocol.derive/3 placed outside of any module:\n\n    Protocol.derive(Jason.Encoder, NameOfTheStruct, only: [...])\n    Protocol.derive(Jason.Encoder, NameOfTheStruct)\n",
   protocol: Jason.Encoder,
   value: %Test{bar: 2, baz: 3, foo: 1}
 }}

From what I understand, it looks like I can only select/exclude keys to serialize, I cannot transform/add new keys. Since I own the structure in question, using Protocol.derive is not necessary.

However I fail to understand how I can leverage the Jason.Encoder protocol to achieve what I want.

Questions

  1. Is my objective possible using the Jason library, or is this a limitation?
  2. Am I miss understanding the documentation and doing something incorrect?
1

There are 1 answers

0
begedin On BEST ANSWER

My guess is, this is due to writing the protocol inside a test file. Protocol consolidation happens before the test file executes, so the protocol never becomes part of the compiled codebase.

To elaborate with an example...

I did the following in a Phoenix app

  • into the lib folder, I added foo.ex
defmodule Foo do
  defstruct [:a, :b]

  defimpl Jason.Encoder do
    def encode(%Foo{a: a, b: b}, opts) do
      Jason.Encode.map(%{"a" => a, "b" => b}, opts)
    end
  end
end
  • in the test folder, I added foo_test.exs
defmodule FooTest do
  use ExUnit.Case

  defmodule Bar do
    defstruct [:c, :d]

    defimpl Jason.Encoder do
      def encode(%Bar{c: c, d: d}, opts) do
        Jason.Encode.map(%{"c" => c, "d" => d}, opts)
      end
    end
  end

  test "encodes Foo" do
    %Foo{a: 1, b: 2} |> Jason.encode!() |> IO.inspect()
  end

  test "encodes Bar" do
    %Bar{c: 5, d: 6} |> Jason.encode!()
  end
end

Running this test fule, results in the "encodes Foo" passing, but "encodes Bar" fails with a warning

warning: the Jason.Encoder protocol has already been consolidated, an implementation for FooTest.Bar has no effect. If you want to implement protocols after compilation or during tests, check the "Consolidation" section in the Protocol module documentation

followed by an error in the test

** (Protocol.UndefinedError) protocol Jason.Encoder not implemented for %FooTest.Bar{c: 5, d: 6} of type FooTest.Bar (a struct), Jason.Encoder protocol must always be explicitly implemented.

This is because of protocol consolidation happening, causing the Bar protocol to not be compiled.

You can turn off protocol consolidation in the test environment, by adding the following to mix.exs

def project do
  # ...
  consolidate_protocols: Mix.env() != :test,                   
  #...
end

If you do that, the protocol will compile and both tests will pass.

However, the solution is probably to just not write the struct/protocol directly in the test file.