I have the pleasure to introduce you to a new gem created to support a JSON:API-based web applications. Standalone pagination support.
Why another pagination gem?
Well, I know what are you thinking - WHY I bothered to build another pagination gem? There are plenty of pagination plugins for ruby web applications out there, to list just a few popular ones:
The first two are "kaminari" and "will_paginate". Both are closely tight to ActiveRecord which can be a good or bad thing depending on your needs. For sure, implementing any of those above is extremely easy as they just extend your models!
However, they have two big issues.
- It's super hard to use them out of the ActiveRecord-based collections.
- They are slow.
Fortunately, smart people out there already addressed both of those problems and invented Pagy.
Pagy is a much more attractive gem, as it's dozens of times faster than any of the solutions listed above and really framework-agnostic. It can be used in any Rack applications and I use it (directly or indirectly) in all my projects no matter what framework I play with.
However, It also comes with at least 2 issues I think are important.
1. The architectural flaws
First of the issues I've faced is that Pagy based on modules and a lot of assumptions that forces the user to do a lot of configuration in case she wants to use the gem outside of standard Rails-Based controllers.
For example, pagy assumes that there is a params
method accessible in the class you include the Pagy::Backend
module. If we want to add metadata and related link information, we need to require an extension which also needs to be included, but this one assumes, we have a request
method accessible!
All that is not a problem if we want to use it inside of Rails applications, but it generates issues in case of more fancy use cases.
Let's say, we want to have a Query
object, that will select the records from the database, and deliver the paginated result. Or an Endpoint
instance, where you would love to get as a result already serialized and paginated response. Instead of injecting the dependencies like a request
instance into the Pagy object, we need to define methods in the caller class.
This is a serious flaw in the architecture, as the caller needs to have wide knowledge about how the paginator paginates resources. Also, the pagination engine used by the caller forces the specific implementation on the caller which makes it really hard to replace in the future if there is a need for that.
I'm not sure why it had been designed this way, where the pagination control is based on overriden methods and global state, but it's unnecessary complexity and a lot of problems introduced without a need for that. There is a much simpler way.
The JSON:API support
The other issue is, that as this gem is so framework-agnostic, there is always a bit of code you'd need to write to make it compatible with JSON:API standard and not only for that. For example, pagy by default assumes, that you have page
and per_page
parameters and will crash for custom pagination params. As JSON:API suggests to control your pagination using a nested query parameter page[size]
and page[number]
, The pagy gem needs to be adjusted in order to use it effectively.
As I work with multiple projects everyday, it would be really tedious to repeat that over and over again.
The JSOM:Pagination
To solve both of the issues listed above, I've decided to write a tiny wrapper around pagy
, to deliver a convenient experience for any JSON API-compatible app - and named it: jsom-pagination
.
JSOM:Pagination is a simple class that we can use in any place of the application (not only in the controllers!) - and in any kind of ruby application. You don't need additional fancy configuration, you don't need different modules being included to use it in Rails or Hanami, or raw IRB session!
Here are two examples how to use it in your app.
First you need to instantiate. the instance of a paginator
require 'jsom-pagination'
paginator = JSOM::Pagination::Paginator.new
And to paginate an array you just need to call it with a collection, parameters hash and the base URL value - to generate the related pagination links, like next, last, and so on.
collection = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
pagination_params = { number: 2, size: 3 }
paginated = paginator.call(collection, params: pagination_params, base_url: 'https://example.com')
In Rails controllers and ActiveRecord collection, it would look like this:
collection = Article.published
pagination_params = params.permit(page: [:number, size]))[:page]
paginated = paginator.call(collection, params: pagination_params, base_url: request.url)
So what is the cool thing about it?
It may not look amazing at the start, but injecting the dependencies into the paginator allows us to run the pagination everywhere easily. Also, the JSON:API support is built in.
All the meta information is accessible right away:
paginated.meta
# => #<JSOM::Pagination::MetaData total=10 pages=4>
paginated.meta.to_h
# => {:total=>10, :pages=>4}
The same applies to links to the related pages.
paginated.links
# => #<JSOM::Pagination::Links:0x00007fdf5d0dd2b8 @url="https://example.com", @page=#<JSOM::Pagination::Page number=2 size=3>, @total_pages=4, @first="https://example.com?page[size]=3", @prev="https://example.com?page[size]=3", @self="https://example.com?page[number]=2&page[size]=3", @next="https://example.com?page[number]=3&page[size]=3", @last="https://example.com?page[number]=4&page[size]=3">
paginated.links.to_h
# => {
# :first=>"https://example.com?page[size]=3",
# :prev=>"https://example.com?page[size]=3",
# :self=>"https://example.com?page[number]=2&page[size]=3",
# :next=>"https://example.com?page[number]=3&page[size]=3",
# :last=>"https://example.com?page[number]=4&page[size]=3"
# }
And that's all.
Serializing the paginated collection
jsom-pagination
does not force you to use any particular serialization method to return those data. You can use anything you want to serialize the result. For example, if you want to use jsonapi-serializer, here is how to do it
options = { meta: paginated.meta.to_h, links: paginated.links.to_h }
render json: ArticleSerializer.new(paginated.items, options)
Something extra for Rails users
Specifically for rails users, I've written a simple concern, where we can collect all the pagination-related methods to simplify the usage as much as possible.
# app/controllers/concerns/paginable.rb
# Collects methods related to paginating resources
# Requires 'serializer' to be defined.
#
module Paginable
extend ActiveSupport::Concern
def paginate(collection)
paginator.call(
collection,
params: pagination_params,
base_url: request.url
)
end
def paginator
JSOM::Pagination::Paginator.new
end
def pagination_params
params.permit(page: [:number, :size])[:page]
end
def render_collection(paginated)
options =
{
meta: paginated.meta.to_h,
links: paginated.links.to_h
}
result = serializer.new(paginated.items, options)
render json: result, status: :ok
end
end
With this, we can use render_collection
method, to serialize the collection under the hood:
# app/controllers/articles_controller.rb
def index
paginated = paginate(Article.all)
render_collection(paginated)
end
Now, the controller knows almost nothing about how to paginate collections, and we can easily use this feature wherever we want, keeping our classes skinny and easy to manage.
It's a huge improvement and I hope you enjoyed it so far!
Summary
Writing useful gems is not a trivial thing. But in Ruby world, I see things often overcomplicated and assuming too much. It's surprising that such a simple problem as pagination generates so many issues during the implementation and usage of the suggested solutions.
I hope that jsom-pagination will help you go around them!
Let me know on Twitter what do you think about it!