A behaviour to reduce boilerplate code in your JSON-API compliant Phoenix controllers without sacrificing flexibility.
Clone or download
alanpeabody Merge pull request #80 from jherdman/patch-1
Add Note About `filter` Key Casing
Latest commit d433980 Oct 29, 2018
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
config Initial commit Jan 5, 2016
lib fix render_show arity Aug 16, 2018
test fix render_show arity Aug 16, 2018
.gitignore Initial commit Jan 5, 2016
.travis.yml Add travis.yml Aug 28, 2016
LICENSE Update License Nov 17, 2016
README.md Add Note About `filter` Key Casing Oct 28, 2018
mix.exs Add render_index and render_show Aug 13, 2018
mix.lock Add render_index and render_show Aug 13, 2018

README.md

JaResource

Build Status Hex Version

A behaviour to reduce boilerplate code in your JSON-API compliant Phoenix controllers without sacrificing flexibility.

Exposing a resource becomes as simple as:

defmodule MyApp.V1.PostController do
  use MyApp.Web, :controller
  use JaResource # or add to web/web.ex
  plug JaResource
end

JaResource intercepts requests for index, show, create, update, and delete actions and dispatches them through behaviour callbacks. Most resources need only customize a few callbacks. It is a webmachine like approach to building APIs on top of Phoenix.

JaResource is built to work in conjunction with sister library JaSerializer. JaResource handles the controller side of things while JaSerializer is focused exclusively on view logic.

See Usage for more details on customizing and restricting endpoints.

Rationale

JaResource lets you focus on the data in your APIs, instead of worrying about response status, rendering validation errors, and inserting changesets. You get robust patterns and while reducing maintenance overhead.

At Agilion we value moving quickly while developing quality applications. This library has come out of our experience building many APIs in a variety of fields.

Installation

If available in Hex, the package can be installed by:

  1. Adding ja_resource to your list of dependencies in mix.exs:

    def deps do [{:ja_resource, "~> 0.1.0"}] end

  2. Ensuring ja_resource is started before your application:

    def application do [applications: [:ja_resource]] end

  3. ja_resource can be configured to execute queries on a given repo. While not required, we encourage doing so to preserve clarity:

    config :ja_resource, repo: MyApp.Repo

  4. JaSerializer / JSON-API setup. JaResource is built to work with JaSerializer. Please refer to https://github.com/vt-elixir/ja_serializer#phoenix-usage to setup Plug and Phoenix for JaSerializer and JaResource.

Usage

For the most simplistic resources JaSerializer lets you replace hundreds of lines of boilerplate with a simple use and plug statements.

The JaResource plug intercepts requests for standard actions and queries, filters, create changesets, applies changesets, responds appropriately and more all for you.

Customizing each action just becomes implementing the callback relevant to what functionality you want to change.

To expose index, show, update, create, and delete of the MyApp.Post model with no restrictions:

defmodule MyApp.V1.PostController do
  use MyApp.Web, :controller
  use JaResource # Optionally put in web/web.ex
  plug JaResource
end

You can optionally prevent JaResource from intercepting actions completely as needed:

defmodule MyApp.V1.PostsController do
  use MyApp.Web, :controller
  use JaResource
  plug JaResource, except: [:delete]

  # Standard Phoenix Delete
  def delete(conn, params) do
    # Custom delete logic
  end
end

And because JaResource is just implementing actions, you can still use plug filters just like in normal Phoenix controllers, however you will want to call the JaResource plug last.

defmodule MyApp.V1.PostsController do
  use MyApp.Web, :controller
  use JaResource

  plug MyApp.Authenticate when action in [:create, :update, :delete]
  plug JaResource
end

You are also free to define any custom actions in your controller, JaResource will not interfere with them at all.

defmodule MyApp.V1.PostsController do
  use MyApp.Web, :controller
  use JaResource
  plug JaResource

  def publish(conn, params) do
   # Custom action logic
  end
end

Changing the model exposed

By default JaResource parses the controller name to determine the model exposed by the controller. MyApp.UserController will expose the MyApp.User model, MyApp.API.V1.CommentController will expose the MyApp.Comment model.

This can easily be overridden by defining the model/0 callback:

defmodule MyApp.V1.PostsController do
  use MyApp.Web, :controller
  use JaResource

  def model, do: MyApp.Models.BlogPost
end

Customizing records returned

Many applications need to expose only subsets of a resource to a given user, those they have access to or maybe just models that are not soft deleted. JaResource allows you to define the records/1 and record/2

records/1 is used by index, show, update, and delete requests to get the base query of records. Many controllers will override this:

defmodule MyApp.V1.MyPostController do
  use MyApp.Web, :controller
  use JaResource

  def model, do: MyApp.Post
  def records(%Plug.Conn{assigns: %{user_id: user_id}}) do
    model
    |> where([p], p.author_id == ^user_id)
  end
end

record/2 receives the conn and the id param and returns a single record for use in show, update, and delete. The default implementation calls records/1 with the conn, then narrows the query to find only the record with the expected id. This is less common to customize, but may be useful if using non-id fields in the url:

defmodule MyApp.V1.PostController do
  use MyApp.Web, :controller
  use JaResource

  def record(conn, slug_as_id) do
    conn
    |> records
    |> MyApp.Repo.get_by(slug: slug_as_id)
  end
end

'Handle' Actions

Every action not excluded defines a default handle_ variant which receives pre-processed data and is expected to return an Ecto query or record. All of the handle calls may also return a conn (including the result of a render call).

An example of customizing the index and show actions (instead of customizing records/1 and record/2) would look something like this:

defmodule MyApp.V1.PostController do
  use MyApp.Web, :controller
  use JaResource

  def handle_index(conn, _params) do
    case conn.assigns[:user] do
      nil -> where(Post, [p], p.is_published == true)
      u   -> Post # all posts
    end
  end

  def handle_show(conn, id) do
    Repo.get_by(Post, slug: id)
  end
end

Filtering and Sorting

The handle_index has complimentary callbacks filter/4 and sort/4. These two callbacks are called once for each value in the related param. The filtering and sorting is done on the results of your handle_index/2 callback (which defaults to the results of your records/1 callback).

For example, given the following request:

GET /v1/articles?filter[category]=dogs&filter[favourite-snack]=cheese&sort=-published

You would implement the following callbacks:

defmodule MyApp.ArticleController do
  use MyApp.Web, :controller
  use JaSerializer

  def filter(_conn, query, "category", category) do
    where(query, category: ^category)
  end
  
  def filter(_conn, query, "favourite_snack", snack) do
    where(query, favourite_snack: ^favourite_snack)
  en

  def sort(_conn, query, "published", direction) do
    order_by(query, [{^direction, :inserted_at}])
  end
end

Note that in the case of filter[favourite-snack] JaResource has already helpfully converted the filter param's name from dasherized to underscore (or from whatever you configured your API to use).

Paginate

The handle_index_query/2 can be used to apply query params and render_index/3 to serialize meta tag.

For example, given the following request:

GET /v1/articles?page[number]=1&page[size]=10

You would implement the following callbacks:

defmodule MyApp.ArticleController do
  use MyApp.Web, :controller
  use JaSerializer

  def handle_index_query(%{query_params: params}, query) do
    number = String.to_integer(params["page"]["number"])
    size = String.to_integer(params["page"]["size"])
    total = from(t in subquery(query), select: count("*")) |> repo().one()

    records =
      query
      |> limit(^(number + 1))
      |> offset(^(number * size))
      |> repo().all()

    %{
      page: %{
        number: number,
        size: size
      },
      total: total,
      records: records
    }
  end

  def render_index(conn, paginated, opts) do
    conn
    |> Phoenix.Controller.render(
      :index,
      data: paginated.records,
      opts: opts ++ [
        meta: %{
          page: paginated.page,
          total: paginated.total
        }
      ]
    )
  end
end

Creating and Updating

Like index and show, customizing creating and updating resources can be done with the handle_create/2 and handle_update/3 actions, however if just customizing what attributes to use, prefer permitted_attributes/3.

For example:

defmodule MyApp.V1.PostController do
  use MyApp.Web, :controller
  use JaResource

  def permitted_attributes(conn, attrs, :create) do
    attrs
    |> Map.take(~w(title body type category_id))
    |> Map.merge("author_id", conn.assigns[:current_user])
  end

  def permitted_attributes(_conn, attrs, :update) do
    Map.take(attrs, ~w(title body type category_id))
  end
end

Note: The attributes map passed into permitted_attributes is a "flattened" version including the values at data/attributes, data/type and any relationship values in data/relationships/[name]/data/id as name_id.

Create

Customizing creation can be done with the handle_create/2 function.

defmodule MyApp.V1.PostController do
  use MyApp.Web, :controller
  use JaResource

  def handle_create(conn, attributes) do
    Post.publish_changeset(%Post{}, attributes)
  end
end

The attributes argument is the result of the permitted_attributes function.

If this function returns a changeset it will be inserted and errors rendered if required. It may also return a model or validation errors for rendering or a %Plug.Conn{} for total rendering control.

By default this will call changeset/2 on the model defined by model/0.

Update

Customizing update can be done with the handle_update/3 function.

defmodule MyApp.V1.PostController do
  use MyApp.Web, :controller
  use JaResource

  def handle_update(conn, post, attributes) do
    current_user_id = conn.assigns[:current_user].id
    case post.author_id do
      ^current_user_id -> {:error, author_id: "you can only edit your own posts"}
      _                -> Post.changeset(post, attributes, :update)
    end
  end
end

If this function returns a changeset it will be inserted and errors rendered if required. It may also return a model or validation errors for rendering or a %Plug.Conn{} for total rendering control.

The record argument (post in the above example) is the record found by the record/3 callback. If record/3 can not find a record it will be nil.

The attributes argument is the result of the permitted_attributes function.

By default this will call changeset/2 on the model defined by model/0.

Delete

Customizing delete can be done with the handle_delete/2 function.

def handle_delete(conn, post) do
  case conn.assigns[:user] do
    %{is_admin: true} -> super(conn, post)
    _                 -> send_resp(conn, 401, "nope")
  end
end

The record argument (post in the above example) is the record found by the record/2 callback. If record/2 can not find a record it will be nil.

Custom responses

It is possible to override the default responses for create and update actions in both the success and invalid cases.

Create

Customizing the create response can be done with the render_create/2 and handle_invalid_create/2 functions. For example:

defmodule MyApp.V1.PostController do
  use MyApp.Web, :controller
  use JaResource

  def render_create(conn, model) do
    conn
    |> Plug.Conn.put_status(:ok)
    |> Phoenix.Controller.render(:show, data: model)
  end

  def handle_invalid_create(conn, errors),
    conn
    |> Plug.Conn.put_status(401)
    |> Phoenix.Controller.render(:errors, data: errors)
  end
end

Update

Customizing the update response can be done with the render_update/2 and handle_invalid_update/2 functions. For example:

defmodule MyApp.V1.PostController do
  use MyApp.Web, :controller
  use JaResource

  def render_update(conn, model) do
    conn
    |> Plug.Conn.put_status(:created)
    |> Phoenix.Controller.render(:show, data: model)
  end

  def handle_invalid_update(conn, errors) do
    conn
    |> Plug.Conn.put_status(401)
    |> Phoenix.Controller.render(:errors, data: errors)
  end
end