Problem

I needed a JSON output from Phoenix framework, but it should be based on predefined template (let’s say externally generated JSON with just a couple of variable fields, depending on the environment etc). So, generating the JSON from a data structure in my Phoenix view was out of the question. The most logical solution seems to be just putting the template JSON into an… erm… eex template, and generating it as you would any web page.

Naive approach

So, an initial, intuitive approach was the following:

# my_controller.ex
defmodule Papp.MyController do
  use Papp.Web, :controller

  def from_template(conn, _args) do
    render(conn, "from_template.json")
  end
end
# from_template.json.eex
{"message":"Hello World!"}

But when you fire up the server and get the response, you’d be unpleasantly surprised:

"{\"message\":\"Hello World!\"}"

What?! Your JSON is now JSON-encoded string containing your desired JSON.

It turns out, that’s not that trivial with Phoenix. Views in Phoenix are just bunch of functions, and they return to the controller whatever needs to be rendered. When it comes to JSON, Phoenix is helpful enough to allow us to return whatever data structure from the view, and Phoenix will happily serialize it to JSON somewhere between the view and the controller. There’s the catch: Phoenix does not make the difference between content generated by a view (say, a hashmap an API should encode to JSON and return) and a content from a JSON template, which is already JSON encoded and just needs to be passed to the connection. That’s why Phoenix assumes our template is a plain string and it sends it as a string, encoding it first. Definitely not what we need.

Solution 1: Using “non-JSON” JSON template

Obviously, Phoenix is treating JSON templates differently, so why not pretending our JSON is not JSON? Simply renaming our template to something like from_template.jsonr.eex (“r” for “raw”, or whatever you like) solves the issue - our output is not double encoded anymore. However, when you do not use JSON as an extension anymore, then your response’s content-type won’t be OK anymore, so you’ll need to force it:

# my_controller.ex
defmodule Papp.MyController do
  use Papp.Web, :controller

  def from_template(conn, _args) do
    conn
    |> put_resp_content_type("application/json")
    |> render("from_template.jsonr")
  end
end
# from_template.jsonr.eex
{"message":"Hello World!"}

Solution 2: Skip encoding by direct rendering

If we call view’s render/3 function directly from the controller (instead of using render/2 from the Phoenix.Controller), the encoding part is skipped (it happens in render_to_iodata/3 function):

# my_controller.ex
def from_template(conn, _args) do
  conn
  |> put_resp_content_type("application/json")
  |> send_resp(200, Phoenix.View.render(Papp.MyView, "eex_template.json", []))
end

Solution 3: Custom format encoder and custom template engine

Chris McCord heard about the problem and suggested that, if I would like to work around the problem properly, I could create custom format encoder, which would differently treat input from template and from the view. Although it can guess by the type of data (the template returns a string - binary, the view returs a structure), the cleaner approach would be sending the metadata (simple :raw will do) along with the content. For that, a custom template engine could be put together:

# papp_eej_engine.ex
defmodule Papp.EejEngine do
  def compile(path, name) do
    {:raw, EEx.compile_file(path, engine: EEx.SmartEngine, line: 1, trim: true)}
  end
end

# papp_json_encoder.ex
defmodule Papp.JsonEncoder do
  def encode_to_iodata!({:raw, json}) when is_binary(json), do: json
  def encode_to_iodata!(json) do
    Poison.encode_to_iodata!(json)
  end
end

Of course, one needs to configure the custom stuff:

# config.exs
config :phoenix, :format_encoders,
  html: Phoenix.HTML.Engine,
  json: Papp.JsonEncoder

config :phoenix, :template_engines,
  eex: Phoenix.Template.EExEngine,
  exs: Phoenix.Template.ExsEngine,
  eej: Papp.EejEngine

Conclusion

I am very thankful to Chris and all the guys that tried to help at the Slack channel. Chris said that it’s currently unlikely that this feature would end up in the framework itself, as nobody except us asked for it so far (I admit that generating JSON from template seems unnecessary and intially as a code smell - however we have really good reason for it). I can understand the need to keep the framework as lean as possible, and that’s why I wrote this blog post, to help these few that face similar problem in the future. Happy coding!