structelixirelixir-jason

How to have custom enconding for struct using Jason?


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?

Solution

  • 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

    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
    
    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.