Content Negotation lets you render HTML and JSON in the same route.


License
GPL-3.0

Documentation

content (negotiation plug)

content adds Content Negotiation to any Phoenix App so you can render HTML and JSON for the same route.

Build Status codecov.io contributions welcome HitCount

Why? 🤷

We need to reduce eliminate duplication of effort while building our App+API so we can ship features much faster.
Using this Plug we are able to build our App (Phoenix Web UI) and a REST (JSON) API in the same codebase with minimal effort.

What? 💭

A Plug that can be added to any Phoenix App to render both HTML and JSON in the same route/controller so that we save dev time. By ensuring that all Web UI has a corresponding JSON response we guarantee that everyone has access to their data in the most convenient way.

Returning an HTML view for people using the App in a Web Browser and return JSON for people requesting the same endpoint from a script (or a totally independent front-end) we guarantee that all features of our Web App are automatically available in the API.

We have built several Apps and APIs in the past and felt the pain of having to maintain two separate codebases. It's fine for mega corp with hundreds/thousands of developers to maintain a separate web UI and API applications. We are a small team that has to do (a lot) more with fewer resources!

If you are new to content negotiation in general or how to implment it in Phoenix from scratch, please see: dwyl/phoenix-content-negotiation-tutorial

Who? 👥

This project is "for us by us". We are using it in our product in production. It serves our needs exactly. As with everything we do it's Open Source so that anyone else can benefit. If it looks useful to you, use it! If you have any ideas/requests for features, please open an issue.

How? 💡

In less than 2 minutes and 3 easy steps you will have content negotiation enabled in your Phoenix App and can get back to building your app!


1. Install ⬇️

Add content to your list of dependencies in mix.exs:

def deps do
  [
    {:content, "~> 1.3.0"}
  ]
end

Then run mix deps.get.


2. Add the Content Plug to your router.ex 🔧

Open the router.ex file in your Phoenix App. Locate the pipeline :browser do section. And replace it:

Before:

pipeline :browser do
  plug :accepts, ["html"]
  plug :fetch_session
  plug :fetch_flash
  plug :protect_from_forgery
  plug :put_secure_browser_headers
end

After:

pipeline :any do
  plug :accepts, ["html", "json"]
  plug Content, %{html_plugs: [
    &fetch_session/2,
    &fetch_flash/2,
    &protect_from_forgery/2,
    &put_secure_browser_headers/2
  ]}
end

Pass the plugs you want to run for html as html_plugs (in the order you want to execute them).

Note: the & and /2 additions to the names of plugs are the Elixir way of passing functions by reference.
The & means "capture" and the /2 is the arity of the function we are passing.
We would obviously prefer if functions were just variables like they are in some other programming languages, but this works.
See: https://dockyard.com/blog/2016/08/05/understand-capture-operator-in-elixir
and: https://culttt.com/2016/05/09/functions-first-class-citizens-elixir

Example: router.ex#L6-L11


3. Use the Content.reply/5 in your Controller 📣

In your controller(s), add the following line to invoke Content.reply/5
which will render HTML or JSON depending on the accept header:

Content.reply(conn, &render/3, "index.html", &json/2, data)

Again, those & and /3 are just to let Elixir know which render and json function to use.

The Content.reply/5 accepts the following 5 argument:

  1. conn - the Plug.Conn where we get the req_headers from.
  2. render/3 - the Phoenix.Controller.render/3 function, or your own implementation of a render function that takes conn, template and data as it's 3 params.
  3. template - the .html template to be rendered if the accept header matches "html"; e.g: "index.html"
  4. json/2 - the Phoenix.Controller.json/2 function that renders json data. Or your own implementation that accepts the two params: conn and data corresponding to the Plug.Conn and the json data you want to return.
  5. data - the data we want to render as HTML or JSON.

Example: quotes_controller.ex#L13

If you need more control over the rendering of HTML or JSON, you can always write custom logic such as:

if Content.get_accept_header(conn) =~ "json" do
  data = transform_data(q)
  json(conn, data)
else
  render(conn, "index.html", data: q)
end

4. Wildcard Routing

If you want to allow people to view the JSON representation of any route in your application in a Web Browser without having to manually set the Accept header to application/json, there's a handy function for you: wildcard_redirect/3

To use it, simply create a wildcard route in your router.ex file. e.g:

get "/*wildcard", QuotesController, :redirect

And create the corresponding controller to handle this request:

def redirect(conn, params) do
  Content.wildcard_redirect(conn, params, AppWeb.Router)
end

The 3 arguments for wildcard_redirect/3 are:

  • conn - a Plug.Conn the usual for a Phoenix controller.
  • params - the params for the request, again standard for a Phoenix controller.
  • router - the router module for your Phoenix App e.g: MyApp.Router

For an example of this in action, see: README.md#10-view-json-in-a-web-browser

Error Handling

If a route does not exist in your app you will see an error. To handle this error you can use a Try Catch, e.g:

try do
  Content.wildcard_redirect(conn, params, AppWeb.Router)
rescue
  # below this line will only render if redirect fails:
  UndefinedFunctionError ->
    conn
    |> Plug.Conn.send_resp(404, "not found")
    |> Plug.Conn.halt()
end

Alternatively, for a more robust approach to Error handling, see action_fallback/1: https://hexdocs.pm/phoenix/Phoenix.Controller.html#action_fallback/1


If you get stuck at at any point, please reference our tutorial: /dwyl/phoenix-content-negotiation-tutorial


Docs? 📖

Documentation can be found at https://hexdocs.pm/content.


Love it? Want More?

If you are using this package in your project, please the repo on GitHub.
If you have any questions/requests, please open an issue.