Repository: jeregrine/jsonapi
Pull request #189 (2019-03-16 18:51:35)

Reimplementing pagination
This implements page/offset/cursor pagination strategies in the  serializer by passing a `JSONAPI.Page` object to the `serialize` function and implementing basic page, offset and cursor strategies. It also gets rid of the now useless `with_pagination` knob.

lucacorti: After some thought, I think pagination strategies do not belong into the serializer. The JSONAPI spec does not mandate any particular strategy, it just reserves the use of the `page` query param for pagination.

So, my latest commit provides an extensible behavior for dealing with pagination which basically boils down to delegating pagination links to the view `pagination_links` overridable function.

The deafult implementation of the function provides no links, unless a `paginator` option is provided to `use JSONAPI.View` or a global `paginator` configuration is provided. These point to a module conforming to `JSONAPI.Paginator` which defines one callback function to delegate links generation to. This allows users to implement custom pagination strategies. The page struct from the conn assigns is used to retrieve total items/pages information which can be patched in the conn at the controller level.

An implementation of a page-based strategy is implemented. Tests are updated and demonstrate the usage.

I think this improves on the previous PR, thoughts?
doomspork: Sorry for missing this PR @lucacorti! We'll get eyes on it this week and let you know our thoughts, thanks for getting involved 😁 
lucacorti: The failing test esposes a bug (#191) in the parser, which ends up populating the JSONAPI.Page struct with string values from the query parametes, which in turn break the paginator which expects integers as per `JSONAPI.Page` typespec.
jherdman: Hey friends. Sorry for taking so long to get to this. Life, etc.

I haven't used the current pagination support, so I have some background questions:

- [ ] What are the problems with the current implementation?
- [ ] Does pagination even belong in the "core" of this project? Should it be extracted into a supporting addon?
lucacorti: JSON API mandates the use of the `page` query params and defines standard pagination links format in the response, beyond that everything is implementation specific. So I think generic pagination support belongs in the library. My PR goes in this direction. The provided page-based implementation is merely an example implementation. I guess not shipping it or any other specific implementation would be fine, but could be nice to have out of the box.

The main problem with the current implementation from my point of view is everything is done into the links callback and doesn't offer much flexibility. In my opinion the links callback is a good place to address custom links, while being able to delegate pagination strategies allows to define a clear interface, configure pagination at project/view level and possibly override the pagination_links for even more flexibility.

So IMHO basic pagination support belongs in the project, specific pagination implementations maybe not.

Another thing I'd like to add to my PR to go down the spec compliance route even more is allowing the user to specify which query params map to which `JSONAPI.Page` struct members, because the usage of specific parameters is outside the scope of the spec and the library is enforcing the use of specific parameter names. Again, the current ones may stay as defaults, but the user should be able to verride them.
lucacorti: Latest commit fixes #191 and adds support for custom query parameters for pagination via

```
config :jsonapi, page_query_param, "mypage"
```

For all pagination query params inside `page[]`.
jherdman: Generally speaking I feel like this PR needs to be approached slightly differently. Currently:

* There's a fix for #191 in it
* It removes existing behaviour
* Adds new behaviour

I know this will be more work and it's a lot to ask from someone freely contributing their time, but I'd like to see an alternate approach.

1. Extract the fix for #191 into a distinct PR. This gives us the option of releasing it independently to see feedback from users of this package.
2. Construct this PR in a way that offers existing users of the pagination approach a cleaner, and documented, way to upgrade. My understanding is that this code would break existing consumption of this package, thus necessitating a major release without a release informing of a deprecated pathway, and a pathway to upgrade.
lucacorti: @jherdman Sure, I'll make a separate PR for #191.

As far as this PR goes, documentation is the next step. If a major release is acceptable for the project, it should suffice, otherwise I'd like to discuss ways in which the upgrade path should be smoothed.
lucacorti: @jherdman 

So, #192 addresses #191.

Also I gave some more thought about the upgrade path and realized my PR already addresses this in a backwards compatible way. Pagination links currently generated in the view `links` callback should be preserved if the user doesn't opt-in to the new pagination style by either implementing the `pagination_links` callback or by passing a paginator module to the view/config.

I guess properly documenting this behavior and how to migrate to the new one should be enough.
lucacorti: So, I'd like to recap where I'm trying to go with this PR.

The current implementation has a few issues:

1. It does not expose a simple and clear interface to handle pagination.
2. It enforces a specific way to pass pagination parameters and the type of values int these which is not in line with the JSON API spec and limits pagination schemes that can be implemented.

What this PR does:

1. Moves pagination link generation to a new `pagination_links` callback in the view, which can be used to provide pagination links.
2. Provides a default implementation of the pagination links callback which delegates pagination to a module which handles pagination link generation through a well defined behavior. The pagination behavior can be implemented, configured globally, overriden per view.
3. Gets rid of the `JSONAPI.Page` struct and value parsing and just rely on the `page` query parameter contents to provide context for pagination, which is all JSON:API mandates. It also adds a way to pass needed context back to the pagination module (ie. total_items/total_pages) from the controller.
4. Provides backwards compatibility. If the user does not opt in to the new system, pagination links generated in the links callback should still work as expected.

What is missing to complete the work

1. Documentation about the new behaviour and how to opt in to the new pagination api.
jherdman: How hard would it be to resolve the issues in #131 using your implementation?
lucacorti: @jherdman I think this PR and #131 are solving the same problems in two fundamentally different ways. Which issues in #131 you are referring to?

lucacorti: @jherdman About the `Paginator` behavior, its responsibility is to just generate links, actual pagination is done externally (in my case Scrivener paginating Ecto models), because knowledge about the actual data is needed to do that. No assumption is made about how pagination is actually performed.
jherdman: > @jherdman I think this PR and #131 are solving the same problems in two fundamentally different ways. Which issues in #131 are you referring to?

Ah, my bad. I think we can ignore this comment.
lucacorti: @jherdman I've updated the tests and added documentation. I think this is quite complete. Anything else we need to address?
jherdman: @lucacorti LGTM! It needs a rebase, but I'd love to get @doomspork or @snewcomer 's eyes on it too.
doomspork: @jherdman / @lucacorti this looks good to me. Thank you both for putting in so much time to get this across the finish line.
lucacorti: @jherdman  @doomspork Thank you for bearing with me while I iterated on the implementation.
doomspork: Thank _you_ @lucacorti for doing that. It's truly _awesome_ to have open source contributors like you, I cannot stress enough how much we value people like yourself. I hope you'll continue your contributions 😁 

Patch:
diff --git a/README.md b/README.md
index 7e30934..a7d6249 100644
--- a/README.md
+++ b/README.md
@@ -15,9 +15,9 @@ of the JSON:API spec.
 - [x] Relationship links
 - [x] Parsing of `sort` query parameter into Ecto Query order_by
 - [x] Parsing and limiting of `filter` keywords.
-- [x] Handling of sparse fieldsets
-- [x] Handling of includes
-- [x] Handling of pagination
+- [x] Handling of [sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets)
+- [x] Handling of [includes](https://jsonapi.org/format/#fetching-includes)
+- [x] Handling of [pagination](https://jsonapi.org/format/#fetching-pagination)
 - [x] Handling of top level meta data
 
 ## Documentation
@@ -141,7 +141,8 @@ config :jsonapi,
   namespace: "/api",
   field_transformation: :underscore,
   remove_links: false,
-  json_library: Jason
+  json_library: Jason,
+  paginator: nil
 ```
 
 - **host**, **scheme**. By default these are pulled from the `conn`, but may be
@@ -150,9 +151,6 @@ config :jsonapi,
   your resources live at (e.g. given "http://example.com/api/cars", `"/api"`
   would be the namespace). See also `JSONAPI.View` for setting on the resource's
   View itself.
-- **remove_links**. `links` data can optionally be removed from the payload via
-  setting the configuration above to `true`. Defaults to `false`.
-- **json_library**. Defaults to [Jason](https://hex.pm/packages/jason).
 - **field_transformation**. This option describes how your API's fields word
   boundaries are marked. [JSON API Spec (v1.1)](https://jsonapi.org/format/1.1/) recommends using camelCase (e.g.
   `"favoriteColor": blue`). If your API uses camelCase fields, set this value to
@@ -160,6 +158,89 @@ config :jsonapi,
   `"favorite-color": blue`). If your API uses dashed fields, set this value to
   `:dasherize`. If your API uses underscores (e.g. `"favorite_color": "red"`)
   set to `:underscore`.
+- **remove_links**. `links` data can optionally be removed from the payload via
+  setting the configuration above to `true`. Defaults to `false`.
+- **json_library**. Defaults to [Jason](https://hex.pm/packages/jason).
+- **paginator**. Module implementing pagination links generation. Deafults to `nil`.
+
+
+## Pagination
+
+Pagination links can be generated by overriding the `JSONAPI.View.pagination_links/4` callback of your view and returning a map containing the links.
+
+```elixir
+...
+
+def pagination_links(data, conn, page, options) do
+  %{first: nil, last: nil, prev: nil, next: nil}
+end
+...
+```
+
+Alternatively you can define generic pagination strategies by implementing a module
+conforming to the `JSONAPI.Paginator` behavior
+
+```elixir
+defmodule PageBasedPaginator do
+  @moduledoc """
+  Page based pagination strategy
+  """
+
+  @behaviour JSONAPI.Paginator
+
+  @impl true
+  def paginate(data, view, conn, page, options) do
+    number =
+      page
+      |> Map.get("page", "0")
+      |> String.to_integer()
+
+    size =
+      page
+      |> Map.get("size", "0")
+      |> String.to_integer()
+
+    total_pages = Keyword.get(options, :total_pages, 0)
+
+    %{
+      first: view.url_for_pagination(data, conn, %{page | "page" => "1"}),
+      last: view.url_for_pagination(data, conn, %{page | "page" => total_pages}),
+      next: next_link(data, view, conn, number, size, total_pages),
+      prev: previous_link(data, view, conn, number, size)
+    }
+  end
+
+  defp next_link(data, view, conn, page, size, total_pages)
+       when page < total_pages,
+       do: view.url_for_pagination(data, conn, %{size: size, page: page + 1})
+
+  defp next_link(_data, _view, _conn, _page, _size, _total_pages),
+    do: nil
+
+  defp previous_link(data, view, conn, page, size)
+       when page > 1,
+       do: view.url_for_pagination(data, conn, %{size: size, page: page - 1})
+
+  defp previous_link(_data, _view, _conn, _page, _size),
+    do: nil
+end
+```
+
+and configuring it as the global pagination logic in your `mix.config`
+
+```elixir
+config :jsonapi, :paginator, PageBasedPaginator
+```
+
+or as the view pagination logic when using `JSONAPI.View`
+
+```elixir
+use JSONAPI.View, paginator: PageBasedPaginator
+```
+
+Links can be generated using the `JSONAPI.Config.page` information stored in the connection assign `jsonapi_query` and by passing additional information to the `pagination_links/4` callback or your paginator module by passing `options` from your controller.
+
+Actual pagination is expected to be handled in your application logic and is outside the scope of this library.
 
 ## Other
 
diff --git a/lib/jsonapi/config.ex b/lib/jsonapi/config.ex
index 1f71916..0d871d3 100644
--- a/lib/jsonapi/config.ex
+++ b/lib/jsonapi/config.ex
@@ -3,8 +3,6 @@ defmodule JSONAPI.Config do
   Configuration struct containing JSON API information for a request
   """
 
-  alias JSONAPI.Page
-
   defstruct data: nil,
             fields: %{},
             filter: [],
@@ -12,7 +10,7 @@ defmodule JSONAPI.Config do
             opts: nil,
             sort: nil,
             view: nil,
-            page: %Page{}
+            page: %{}
 
   @type t :: %__MODULE__{
           data: nil | map,
@@ -22,6 +20,6 @@ defmodule JSONAPI.Config do
           opts: nil | keyword,
           sort: nil | keyword,
           view: any,
-          page: Page.t()
+          page: nil | map
         }
 end
diff --git a/lib/jsonapi/page.ex b/lib/jsonapi/page.ex
deleted file mode 100644
index 5f7fd5b..0000000
--- a/lib/jsonapi/page.ex
+++ /dev/null
@@ -1,19 +0,0 @@
-defmodule JSONAPI.Page do
-  @moduledoc """
-  Configuration struct containing pagination opts for a request
-  """
-
-  defstruct limit: nil,
-            offset: nil,
-            size: nil,
-            page: nil,
-            cursor: nil
-
-  @type t :: %__MODULE__{
-          limit: nil | non_neg_integer,
-          offset: nil | non_neg_integer,
-          size: nil | non_neg_integer,
-          page: nil | non_neg_integer,
-          cursor: nil | non_neg_integer
-        }
-end
diff --git a/lib/jsonapi/paginator.ex b/lib/jsonapi/paginator.ex
new file mode 100644
index 0000000..9ef6fe6
--- /dev/null
+++ b/lib/jsonapi/paginator.ex
@@ -0,0 +1,20 @@
+defmodule JSONAPI.Paginator do
+  @moduledoc """
+  Pagination strategy behaviour
+  """
+
+  alias Plug.Conn
+
+  @type page :: map()
+
+  @type options :: Keyword.t()
+
+  @type links :: %{
+          first: String.t() | nil,
+          last: String.t() | nil,
+          next: String.t() | nil,
+          prev: String.t() | nil
+        }
+
+  @callback paginate(data :: term, view :: atom, conn :: Conn.t(), page, options) :: links
+end
diff --git a/lib/jsonapi/plugs/query_parser.ex b/lib/jsonapi/plugs/query_parser.ex
index 621f354..7e4a0dc 100644
--- a/lib/jsonapi/plugs/query_parser.ex
+++ b/lib/jsonapi/plugs/query_parser.ex
@@ -1,6 +1,6 @@
 defmodule JSONAPI.QueryParser do
   @behaviour Plug
-  alias JSONAPI.{Config, Page}
+  alias JSONAPI.Config
   alias JSONAPI.Exceptions.InvalidQuery
   alias Plug.Conn
   import JSONAPI.Utils.IncludeTree
@@ -45,7 +45,7 @@ defmodule JSONAPI.QueryParser do
         filter: [title: "my title"] # Easily reduceable into ecto where clauses
         include: [comments: :user] # Easily insertable into a Repo.preload,
         fields: %{"myview" => [:id, :text], "comment" => [:id, :body],
-        page: %JSONAPI.Page{
+        page: %{
           limit: limit,
           offset: offset,
           page: page,
@@ -103,8 +103,7 @@ defmodule JSONAPI.QueryParser do
 
   def parse_pagination(config, map) when map_size(map) == 0, do: config
 
-  def parse_pagination(%Config{} = config, page),
-    do: Map.put(config, :page, struct_from_map(page, %Page{}))
+  def parse_pagination(%Config{} = config, page), do: Map.put(config, :page, page)
 
   @spec parse_filter(Config.t(), keyword()) :: Config.t()
   def parse_filter(config, map) when map_size(map) == 0, do: config
diff --git a/lib/jsonapi/serializer.ex b/lib/jsonapi/serializer.ex
index 3acb058..68de804 100644
--- a/lib/jsonapi/serializer.ex
+++ b/lib/jsonapi/serializer.ex
@@ -19,15 +19,18 @@ defmodule JSONAPI.Serializer do
   Please refer to `JSONAPI.View` for more information. If you are in interested in relationships
   and includes you may also want to reference the `JSONAPI.QueryParser`.
   """
-  @spec serialize(module(), term(), Plug.Conn.t() | nil, map() | nil) :: serialized_doc()
-  def serialize(view, data, conn \\ nil, meta \\ nil) do
-    query_includes =
+  @spec serialize(module(), term(), Plug.Conn.t() | nil, map() | nil, list()) :: serialized_doc()
+  def serialize(view, data, conn \\ nil, meta \\ nil, options \\ []) do
+    {query_includes, query_page} =
       case conn do
-        %Plug.Conn{assigns: %{jsonapi_query: %Config{include: include}}} -> include
-        _ -> []
+        %Plug.Conn{assigns: %{jsonapi_query: %Config{include: include, page: page}}} ->
+          {include, page}
+
+        _ ->
+          {[], nil}
       end
 
-    {to_include, encoded_data} = encode_data(view, data, conn, query_includes)
+    {to_include, encoded_data} = encode_data(view, data, conn, query_includes, options)
 
     encoded_data = %{
       data: encoded_data,
@@ -41,17 +44,17 @@ defmodule JSONAPI.Serializer do
         encoded_data
       end
 
-    merge_links(encoded_data, data, view, conn, remove_links?(), with_pagination?())
+    merge_links(encoded_data, data, view, conn, query_page, remove_links?(), options)
   end
 
-  def encode_data(view, data, conn, query_includes) when is_list(data) do
+  def encode_data(view, data, conn, query_includes, options) when is_list(data) do
     Enum.map_reduce(data, [], fn d, acc ->
-      {to_include, encoded_data} = encode_data(view, d, conn, query_includes)
+      {to_include, encoded_data} = encode_data(view, d, conn, query_includes, options)
       {to_include, acc ++ [encoded_data]}
     end)
   end
 
-  def encode_data(view, data, conn, query_includes) do
+  def encode_data(view, data, conn, query_includes, options) do
     valid_includes = get_includes(view, query_includes)
 
     encoded_data = %{
@@ -61,7 +64,7 @@ defmodule JSONAPI.Serializer do
       relationships: %{}
     }
 
-    doc = merge_links(encoded_data, data, view, conn, remove_links?(), false)
+    doc = merge_links(encoded_data, data, view, conn, nil, remove_links?(), options)
 
     doc =
       case view.meta(data, conn) do
@@ -69,22 +72,23 @@ defmodule JSONAPI.Serializer do
         meta -> Map.put(doc, :meta, meta)
       end
 
-    encode_relationships(conn, doc, {view, data, query_includes, valid_includes})
+    encode_relationships(conn, doc, {view, data, query_includes, valid_includes}, options)
   end
 
-  @spec encode_relationships(Plug.Conn.t(), serialized_doc(), tuple()) :: tuple()
-  def encode_relationships(conn, doc, {view, data, _, _} = view_info) do
+  @spec encode_relationships(Plug.Conn.t(), serialized_doc(), tuple(), list()) :: tuple()
+  def encode_relationships(conn, doc, {view, data, _, _} = view_info, options) do
     view.relationships()
     |> Enum.filter(&data_loaded?(Map.get(data, elem(&1, 0))))
-    |> Enum.map_reduce(doc, &build_relationships(conn, view_info, &1, &2))
+    |> Enum.map_reduce(doc, &build_relationships(conn, view_info, &1, &2, options))
   end
 
-  @spec build_relationships(Plug.Conn.t(), tuple(), tuple(), tuple()) :: tuple()
+  @spec build_relationships(Plug.Conn.t(), tuple(), tuple(), tuple(), list()) :: tuple()
   def build_relationships(
         conn,
         {view, data, query_includes, valid_includes},
         {key, include_view},
-        acc
+        acc,
+        options
       ) do
     rel_view =
       case include_view do
@@ -121,7 +125,9 @@ defmodule JSONAPI.Serializer do
           []
         end
 
-      {rel_included, encoded_rel} = encode_data(rel_view, rel_data, conn, rel_query_includes)
+      {rel_included, encoded_rel} =
+        encode_data(rel_view, rel_data, conn, rel_query_includes, options)
+
       {rel_included ++ [encoded_rel], acc}
     else
       {nil, acc}
@@ -153,29 +159,29 @@ defmodule JSONAPI.Serializer do
     merge_related_links(data, info, remove_links?())
   end
 
-  defp merge_base_links(doc, data, view, conn) do
-    links = Map.merge(%{self: view.url_for(data, conn)}, view.links(data, conn))
+  defp merge_base_links(%{links: links} = doc, data, view, conn) do
+    view_links =
+      data
+      |> view.links(conn)
+      |> Map.merge(links)
+      |> Map.merge(%{self: view.url_for(data, conn)})
 
-    Map.merge(doc, %{links: links})
+    Map.merge(doc, %{links: view_links})
   end
 
-  defp merge_links(doc, data, view, conn, false, true) do
-    pagination_links = view.links(data, conn)
-
-    if Enum.empty?(pagination_links) do
-      Logger.info(
-        "You've set with_pagination but have not defined any pagination links, thus no pagination links will be returned"
-      )
-    end
-
-    merge_base_links(doc, data, view, conn)
+  defp merge_links(doc, data, view, conn, nil, false, _options) do
+    doc
+    |> Map.merge(%{links: %{}})
+    |> merge_base_links(data, view, conn)
   end
 
-  defp merge_links(doc, data, view, conn, false, false) do
-    merge_base_links(doc, data, view, conn)
+  defp merge_links(doc, data, view, conn, page, false, options) do
+    doc
+    |> Map.merge(%{links: view.pagination_links(data, conn, page, options)})
+    |> merge_base_links(data, view, conn)
   end
 
-  defp merge_links(doc, _data, _view, _conn, _remove_links, _with_pagination), do: doc
+  defp merge_links(doc, _data, _view, _conn, _page, _remove_links, _options), do: doc
 
   defp merge_related_links(
          encoded_data,
@@ -236,7 +242,6 @@ defmodule JSONAPI.Serializer do
   end
 
   defp remove_links?, do: Application.get_env(:jsonapi, :remove_links, false)
-  defp with_pagination?, do: Application.get_env(:jsonapi, :with_pagination, false)
 
   defp transform_fields(fields) do
     case JString.field_transformation() do
diff --git a/lib/jsonapi/view.ex b/lib/jsonapi/view.ex
index 657db38..f9258c6 100644
--- a/lib/jsonapi/view.ex
+++ b/lib/jsonapi/view.ex
@@ -116,13 +116,15 @@ defmodule JSONAPI.View do
 
   defmacro __using__(opts \\ []) do
     {type, opts} = Keyword.pop(opts, :type)
-    {namespace, _opts} = Keyword.pop(opts, :namespace)
+    {namespace, opts} = Keyword.pop(opts, :namespace)
+    {paginator, _opts} = Keyword.pop(opts, :paginator)
 
     quote do
-      import JSONAPI.Serializer, only: [serialize: 4]
+      import JSONAPI.Serializer, only: [serialize: 5]
 
       @resource_type unquote(type)
       @namespace unquote(namespace)
+      @paginator unquote(paginator)
 
       def id(nil), do: nil
       def id(%{__struct__: Ecto.Association.NotLoaded}), do: nil
@@ -140,6 +142,16 @@ defmodule JSONAPI.View do
         def namespace, do: Application.get_env(:jsonapi, :namespace, "")
       end
 
+      def pagination_links(data, conn, page, options) do
+        paginator = Application.get_env(:jsonapi, :paginator, @paginator)
+
+        if Code.ensure_loaded?(paginator) && function_exported?(paginator, :paginate, 5) do
+          paginator.paginate(data, __MODULE__, conn, page, options)
+        else
+          %{}
+        end
+      end
+
       defp requested_fields_for_type(%Conn{assigns: %{jsonapi_query: %{fields: fields}}} = conn) do
         fields[type()]
       end
@@ -193,20 +205,17 @@ defmodule JSONAPI.View do
 
       def hidden(data), do: []
 
-      def show(model, conn, _params, meta \\ nil), do: serialize(__MODULE__, model, conn, meta)
-      def index(models, conn, _params, meta \\ nil), do: serialize(__MODULE__, models, conn, meta)
+      def show(model, conn, _params, meta \\ nil, options \\ []),
+        do: serialize(__MODULE__, model, conn, meta, options)
 
-      def url_for(nil, nil) do
-        "#{namespace()}/#{type()}"
-      end
+      def index(models, conn, _params, meta \\ nil, options \\ []),
+        do: serialize(__MODULE__, models, conn, meta, options)
 
-      def url_for(data, nil) when is_list(data) do
-        "#{namespace()}/#{type()}"
-      end
+      def url_for(nil, nil), do: "#{namespace()}/#{type()}"
 
-      def url_for(data, nil) do
-        "#{namespace()}/#{type()}/#{id(data)}"
-      end
+      def url_for(data, nil) when is_list(data), do: "#{namespace()}/#{type()}"
+
+      def url_for(data, nil), do: "#{namespace()}/#{type()}/#{id(data)}"
 
       def url_for(data, %Plug.Conn{} = conn) when is_list(data) do
         "#{scheme(conn)}://#{host(conn)}#{namespace()}/#{type()}"
@@ -231,9 +240,7 @@ defmodule JSONAPI.View do
 
       defp prepare_url("", data, conn), do: url_for(data, conn)
 
-      defp prepare_url(query, data, conn) do
-        "#{url_for(data, conn)}?#{query}"
-      end
+      defp prepare_url(query, data, conn), do: "#{url_for(data, conn)}?#{query}"
 
       if Code.ensure_loaded?(Phoenix) do
         def render("show.json", %{data: data, conn: conn, params: params, meta: meta}),
@@ -263,6 +270,7 @@ defmodule JSONAPI.View do
 
       defoverridable attributes: 2,
                      links: 2,
+                     pagination_links: 4,
                      fields: 0,
                      hidden: 1,
                      id: 1,


Test patch:
diff --git a/test/jsonapi/plugs/query_parser_test.exs b/test/jsonapi/plugs/query_parser_test.exs
index dff916a..f51cba3 100644
--- a/test/jsonapi/plugs/query_parser_test.exs
+++ b/test/jsonapi/plugs/query_parser_test.exs
@@ -131,12 +131,12 @@ defmodule JSONAPI.QueryParserTest do
 
   test "parse_pagination/2 turns a fields map into a map of pagination values" do
     config = struct(Config, view: MyView)
-    assert parse_pagination(config, config.page).page.__struct__ == JSONAPI.Page
-    assert parse_pagination(config, %{"limit" => 1}).page.limit == 1
-    assert parse_pagination(config, %{"offset" => 1}).page.offset == 1
-    assert parse_pagination(config, %{"page" => 1}).page.page == 1
-    assert parse_pagination(config, %{"size" => 1}).page.size == 1
-    assert parse_pagination(config, %{"cursor" => 1}).page.cursor == 1
+    assert parse_pagination(config, config.page).page == %{}
+    assert parse_pagination(config, %{"limit" => "1"}).page == %{"limit" => "1"}
+    assert parse_pagination(config, %{"offset" => "1"}).page == %{"offset" => "1"}
+    assert parse_pagination(config, %{"page" => "1"}).page == %{"page" => "1"}
+    assert parse_pagination(config, %{"size" => "1"}).page == %{"size" => "1"}
+    assert parse_pagination(config, %{"cursor" => "cursor"}).page == %{"cursor" => "cursor"}
   end
 
   test "get_view_for_type/2 raises on invalid fields" do
diff --git a/test/jsonapi/serializer_test.exs b/test/jsonapi/serializer_test.exs
index bd80f6e..14f13c8 100644
--- a/test/jsonapi/serializer_test.exs
+++ b/test/jsonapi/serializer_test.exs
@@ -1,9 +1,7 @@
 defmodule JSONAPI.SerializerTest do
   use ExUnit.Case, async: false
 
-  alias JSONAPI.{Config, Serializer}
-
-  import ExUnit.CaptureLog
+  alias JSONAPI.{Config, QueryParser, Serializer}
 
   defmodule PostView do
     use JSONAPI.View
@@ -18,12 +16,59 @@ defmodule JSONAPI.SerializerTest do
         best_comments: {JSONAPI.SerializerTest.CommentView, :include}
       ]
     end
+  end
+
+  defmodule PageBasedPaginator do
+    @moduledoc """
+    Page based pagination strategy
+    """
+
+    @behaviour JSONAPI.Paginator
+
+    @impl true
+    def paginate(data, view, conn, page, options) do
+      number =
+        page
+        |> Map.get("page", "0")
+        |> String.to_integer()
+
+      size =
+        page
+        |> Map.get("size", "0")
+        |> String.to_integer()
+
+      total_pages =
+        options
+        |> Keyword.get(:total_pages, 0)
 
-    def links(data, conn) do
       %{
-        next: url_for_pagination(data, conn, %{cursor: "some-string"})
+        first: view.url_for_pagination(data, conn, %{page | "page" => "1"}),
+        last: view.url_for_pagination(data, conn, %{page | "page" => total_pages}),
+        next: next_link(data, view, conn, number, size, total_pages),
+        prev: previous_link(data, view, conn, number, size)
       }
     end
+
+    defp next_link(data, view, conn, page, size, total_pages)
+         when page < total_pages,
+         do: view.url_for_pagination(data, conn, %{size: size, page: page + 1})
+
+    defp next_link(_data, _view, _conn, _page, _size, _total_pages),
+      do: nil
+
+    defp previous_link(data, view, conn, page, size)
+         when page > 1,
+         do: view.url_for_pagination(data, conn, %{size: size, page: page - 1})
+
+    defp previous_link(_data, _view, _conn, _page, _size),
+      do: nil
+  end
+
+  defmodule PaginatedPostView do
+    use JSONAPI.View, paginator: PageBasedPaginator
+
+    def fields, do: [:text, :body, :full_description, :inserted_at]
+    def type, do: "mytype"
   end
 
   defmodule UserView do
@@ -536,39 +581,36 @@ defmodule JSONAPI.SerializerTest do
     Application.delete_env(:jsonapi, :remove_links)
   end
 
-  test "serialize includes pagination links if they are defined and with_pagination is configured" do
-    data = %{id: 1}
-    Application.put_env(:jsonapi, :with_pagination, true)
+  test "serialize includes pagination links if page-based pagination is requested" do
+    data = [%{id: 1}]
+    view = PaginatedPostView
 
-    encoded = Serializer.serialize(PostView, data, nil)
+    conn =
+      :get
+      |> Plug.Test.conn("/mytype?page[page]=2&page[size]=1")
+      |> QueryParser.call(%Config{view: view, opts: []})
 
-    assert encoded[:links][:next] ==
-             PostView.url_for_pagination(data, nil, %{cursor: "some-string"})
+    encoded =
+      Serializer.serialize(PaginatedPostView, data, conn, nil, total_pages: 3, total_items: 3)
 
-    Application.delete_env(:jsonapi, :with_pagination)
-  end
+    page = conn.assigns.jsonapi_query.page
+    first = view.url_for_pagination(data, conn, %{page | "page" => 1})
+    last = view.url_for_pagination(data, conn, %{page | "page" => 3})
 
-  test "serialize does not include pagination links if they are not defined even with with_pagination is configured" do
-    data = %{id: 1}
-    Application.put_env(:jsonapi, :with_pagination, true)
-
-    output =
-      capture_log(fn ->
-        encoded = Serializer.serialize(UserView, data, nil)
-
-        refute encoded[:links][:next]
-      end)
-
-    assert Regex.match?(~r/info.*with_pagination/, output)
-    Application.delete_env(:jsonapi, :with_pagination)
+    assert encoded[:links][:first] == first
+    assert encoded[:links][:last] == last
+    assert encoded[:links][:next] == last
+    assert encoded[:links][:prev] == first
   end
 
-  test "serialize does not include pagination links if with_pagination is not configure" do
-    data = %{id: 1}
+  test "serialize does not include pagination links if they are not defined" do
+    data = [%{id: 1}]
 
     encoded = Serializer.serialize(UserView, data, nil)
-
+    refute encoded[:links][:first]
+    refute encoded[:links][:last]
     refute encoded[:links][:next]
+    refute encoded[:links][:prev]
   end
 
   test "serialize can include arbitrary, user-defined, links" do