johnnyji / proper_case

Converts keys of maps to `snake_case`, useful as a plug to format params in the Phoenix connection pipeline
MIT License
89 stars 26 forks source link

JSONEncoder cannot encode structs #20

Closed digitalcora closed 5 years ago

digitalcora commented 6 years ago

In the typical case where data being encoded from Phoenix is a struct (using @derive Poison.Encoder or similar), ProperCase.JSONEncoder.CamelCase does nothing. This doesn't seem well-documented in the README, and makes this encoder not particularly useful out of the box, although it does make sense: the struct-ness cannot be preserved if the keys are changed, but it must be preserved in order to be encoded as desired by Poison/Jason/etc.

To work around this, we're using a custom format encoder that encodes the struct, decodes it back into a map (now with filtered/stringified keys), runs that through ProperCase, and re-encodes it. This would probably be inefficient for large JSON responses, but it's acceptable for our application.

defmodule MyApp.JSONEncoder do
  @moduledoc "JSON encoder that camelizes all object keys in the final JSON output."

  def encode_to_iodata!(data) do
    data
    |> Poison.encode!
    |> Poison.decode!
    |> ProperCase.to_camel_case
    |> Poison.encode_to_iodata!
  end
end

I'm not sure off-hand how this issue could be addressed within ProperCase — there is nowhere in e.g. Poison to insert a "key transform" (although see https://github.com/devinus/poison/issues/44). I think this library could at least be explicit in the README that JSONEncoder.CamelCase only works in the unusual case where the data being encoded is plain maps all the way down, with no structs.

szajbus commented 6 years ago

It's actually stated in code comments:

If the map is a struct with no Enumerable implementation, the struct is considered to be a single value.

steven-cole-elliott commented 5 years ago

@grantovich I, too, had the same realization as you did, and I'm replying with the hopes that perhaps this helps someone else that stumbles upon this useful library but is at a loss momentarily for how to leverage it.

I agree with your assessment that encoding, then decoding so that you can perform the casing transformations, and then encoding again is something that you'd like to avoid for every request if you could.

Trying to avoid that pattern lead me down this approach, though note I'm using Jason rather than Poison, but I think the implementation would be similar.

In my structs, I've defined a an implementation of Jason.Encoder for my given struct, and inside that encode function, I can now do the work that Jason would have done for me for free - that is taking only the keys that I want - and then do what is necessary to be able to leverage ProperCase.to_camel_case before finally doing the encoding via Jason.

I've only played around with it a bit, but the rough idea is

defmodule MyApp.MyStruct do
  use Ecto.Schema
  import Ecto.Changeset

  alias MyApp.Common

  defimpl Jason.Encoder, for: [MyApp.MyStruct] do
    def encode(struct, opts) do
      map =
        struct
        |> Map.take([:key_1, :key_2])
        |> Common.map_from_struct()
        |> ProperCase.to_camel_case()

      Jason.Encode.map(map, opts)
    end
  end

  schema "my_structs" do
    field :key_1, :integer
    field :key_2, :integer

    timestamps()
  end
end

The call to Common.map_from_struct/1 would take some term and convert all structs in it to maps, no matter the level of nesting, so there's no issue with calling ProperCase.to_camel_case later because all structs will be their map representations.

Though you have to write a bit more code, you have as much control as you want over the encoding behavior, and can perform the casing transformations without having to write your own library to do it.

digitalcora commented 5 years ago

Oops, I think I should have closed this a while ago, as we realized the way we were doing JSON rendering did not (did no longer?) align with Phoenix best practices. Specifically, I asserted that

data being encoded from Phoenix is a struct (using @derive Poison.Encoder or similar)

...was a "typical case", which I'm not sure is accurate. The current Phoenix guides, at least, recommend using view modules in a way that results in plain maps being passed to the encoder, which of course works fine with the ProperCase.JSONEncoder. I would recommend anyone finding this issue in the future to consider this approach, since it also results in much better separation of concerns.

I'll close this now since, for me, it is not an issue. If anyone is in a situation that prevents using view modules for whatever reason, the workarounds documented above should help.