Skip to content

Commit

Permalink
Implement JSON:API specifications
Browse files Browse the repository at this point in the history
  • Loading branch information
ddnexus committed Jan 10, 2024
1 parent 447509c commit e35233b
Show file tree
Hide file tree
Showing 14 changed files with 331 additions and 34 deletions.
15 changes: 15 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,18 @@ Style/StringLiterals:
Enabled: true
Exclude:
- test/**/*

Style/HashSyntax:
Enabled: true
Exclude:
- test/**/*

Layout/SpaceAroundOperators:
Enabled: true
Exclude:
- test/**/*

Layout/SpaceInsideHashLiteralBraces:
Enabled: true
Exclude:
- test/**/*
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,25 @@ _(See all the [Backend Tools](https://ddnexus.github.io/pagy/categories/backend/

<details>

<summary>Customization for JSON:API pagination...</summary>

```ruby
# Require the jsonapi extra in the pagy initializer
require 'pagy/extras/jsonapi'

# Use it in your actions
pagy, records = pagy(Product.all)
render json: { data: records,
links: pagy_jsonapi_links(pagy) }
# besides the query params will be nested. E.g.: ?page[number]=2&page[size]=100
```

_(See all the [Backend Tools](https://ddnexus.github.io/pagy/categories/backend/))_

</details>

<details>

<summary>More customization with extras...</summary><br>

Extras add special options and manage different components, behaviors, Frontend or Backend environments... usually by just requiring them (and optionally overriding some default).
Expand All @@ -209,6 +228,7 @@ Extras add special options and manage different components, behaviors, Frontend
- [countless](https://ddnexus.github.io/pagy/docs/extras/countless): Paginate without the need of any count, saving one query per rendering
- [elasticsearch_rails](https://ddnexus.github.io/pagy/docs/extras/elasticsearch_rails): Paginate `ElasticsearchRails` response objects
- [headers](https://ddnexus.github.io/pagy/docs/extras/headers): Add RFC-8288 compliant http response headers (and other helpers) useful for API pagination
- [jsonapi](https://ddnexus.github.io/pagy/docs/extras/jsonapi): Implement the [JSON:API](https://jsonapi.org) specifications for pagination
- [meilisearch](https://ddnexus.github.io/pagy/docs/extras/meilisearch): Paginate `Meilisearch` results
- [metadata](https://ddnexus.github.io/pagy/docs/extras/metadata): Provides the pagination metadata to Javascript frameworks like Vue.js, react.js, etc.
- [searchkick](https://ddnexus.github.io/pagy/docs/extras/searchkick): Paginate `Searchkick::Results` objects
Expand Down
95 changes: 95 additions & 0 deletions docs/extras/jsonapi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
title: Jsonapi
categories:
- Feature
- Backend
- Extra
---

# Jsonapi Extra

Implements the [JSON:API](https://jsonapi.org) specifications for pagination.

When enabled, the query params used in the pagy URLs are nested under the `page` param, as specified by the [Query Parameter Family](https://jsonapi.org/format/#query-parameters-families) e.g. `https://example.com/products?page[number]=2&page[size]=30`.

## Synopsis

### Default usage

||| pagy.rb (initializer)
```ruby
require 'pagy/extras/jsonapi' # works without further configuration
```
|||

||| Controller
```ruby
# enabled by default
@pagy, @records = pagy(collection)
# you can disable it explicitly for specific requests
@pagy, @records = pagy(collection, jsonapi: false)
```
|||

### Custom usage

||| pagy.rb (initializer)
```ruby
# optionally require other jsonapi-useful extras
require 'pagy/extras/items'
# jsonapi must be required AFTER other extras
require 'pagy/extras/jsonapi'
# optionally disable it by default (opt-in)
Pagy::DEFAULT[:jsonapi] = false # default true
```
|||

||| Controller
```ruby
# disabled by default by the above Pagy::DEFAULT[:jsonapi] = false
@pagy, @records = pagy(collection)
# explicitly enable it for specific requests
@pagy, @records = pagy(collection, jsonapi: true)
# optional/custom setup
@pagy, @records = pagy(collection, jsonapi: true, # enable the jsonapi specifications
items_extra: true, # enable the items extra
page_param: :number, # use page[number] param name instead of page[page]
items_params: :size) # use page[size] param name instead of page[items]
# get the links URL hash
links_hash = pagy_jsonapi_links(@pagy)
#=> {first: 'https://example.com/products?page[number]=1&page[size]=50&...',
# last: 'https://example.com/products?page[number]=32&page[size]=50&...',
# prev: 'https://example.com/products?page[number]=31&page[size]=50&...',
# next: 'https://example.com/products?page[number]=33&page[size]=50&...'}
```
|||

## Files

- [items.rb](https://github.com/ddnexus/pagy/blob/master/lib/pagy/extras/jsonapi.rb)

## Variables

| Variable | Description | Default |
|:-----------|:------------------------------|:--------|
| `:jsonapi` | Enable or disable the feature | `true` |

You can use the `:jsonapi` variable to opt-out of the feature even when the extra is required.

## Interaction with other features/extras

This extra just nests the `:page` and `:items` params under the JSON:API reserved `:page` param. You may want to customize the `:page_param` and the `:items` params as shown in the [Synopsis](#synopsis).

You may also want to use it with the [items extra](/docs/extras/items.md) in order to allow the client to request a specific number of items per page and capping it to a max number.

It works also with the [countless](countless.md), [searchkick](searchkick.md), [elasticsearch_rails](elasticsearch_rails.md) and [meilisearch](/docs/extras/meilisearch.md) extras.

It does not make sense (and doesn't work) with the [Calendar](countless.md) extra.

## Methods

The `jsonapi` extra adds the `pagy_jsonapi_links` helper to the `Pagy::Backend` module.

=== `pagy_jsonapi_links(pagy, **opts)`

This helper provides the JSON:API [links for pagination](https://jsonapi.org/format/#fetching-pagination) as a hash of `first`, `last`, `prev`, `next` paths. You can pass the option `asbsolute: true` to get an absolute URL instead.
22 changes: 14 additions & 8 deletions lib/config/pagy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@

# Other Variables
# See https://ddnexus.github.io/pagy/docs/api/pagy#other-variables
# Pagy::DEFAULT[:size] = [1,4,4,1] # default
# Pagy::DEFAULT[:page_param] = :page # default
# Pagy::DEFAULT[:size] = [1,4,4,1] # default
# Pagy::DEFAULT[:page_param] = :page # default
# The :params can be also set as a lambda e.g ->(params){ params.exclude('useless').merge!('custom' => 'useful') }
# Pagy::DEFAULT[:params] = {} # default
# Pagy::DEFAULT[:fragment] = '#fragment' # example
# Pagy::DEFAULT[:link_extra] = 'data-remote="true"' # example
# Pagy::DEFAULT[:i18n_key] = 'pagy.item_name' # default
# Pagy::DEFAULT[:cycle] = true # example
# Pagy::DEFAULT[:request_path] = "/foo" # example
# Pagy::DEFAULT[:params] = {} # default
# Pagy::DEFAULT[:fragment] = '#fragment' # example
# Pagy::DEFAULT[:link_extra] = 'data-remote="true"' # example
# Pagy::DEFAULT[:i18n_key] = 'pagy.item_name' # default
# Pagy::DEFAULT[:cycle] = true # example
# Pagy::DEFAULT[:request_path] = "/foo" # example


# Extras
Expand Down Expand Up @@ -197,6 +197,12 @@
# require 'pagy/extras/standalone'
# Pagy::DEFAULT[:url] = 'http://www.example.com/subdir' # optional default

# Jsonapi extra: Implements JSON:API specifications
# See https://ddnexus.github.io/pagy/docs/extras/jsonapi
# require 'pagy/extras/jsonapi' # must be required after the other extras
# set to false only if you want to make :jsonapi an opt-in variable
# Pagy::DEFAULT[:jsonapi] = false # default true


# Rails
# Enable the .js file required by the helpers that use javascript
Expand Down
8 changes: 7 additions & 1 deletion lib/pagy/backend.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,16 @@ def pagy(collection, vars = {})
def pagy_get_vars(collection, vars)
pagy_set_items_from_params(vars) if defined?(ItemsExtra)
vars[:count] ||= (count = collection.count(:all)).is_a?(Hash) ? count.size : count
vars[:page] ||= params[vars[:page_param] || DEFAULT[:page_param]]
vars[:page] ||= pagy_get_page(vars)
vars
end

# Get the page integer from the params
# Overridable by the jsonapi extra
def pagy_get_page(vars)
(params[vars[:page_param] || DEFAULT[:page_param]] || 1).to_i
end

# Sub-method called only by #pagy: here for easy customization of record-extraction by overriding
# You may need to override this method for collections without offset|limit
def pagy_get_items(collection, pagy)
Expand Down
2 changes: 1 addition & 1 deletion lib/pagy/extras/elasticsearch_rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def pagy_elasticsearch_rails(pagy_search_args, vars = {})
def pagy_elasticsearch_rails_get_vars(_collection, vars)
pagy_set_items_from_params(vars) if defined?(ItemsExtra)
vars[:items] ||= DEFAULT[:items]
vars[:page] ||= (params[vars[:page_param] || DEFAULT[:page_param]] || 1).to_i
vars[:page] ||= pagy_get_page(vars)
vars
end
end
Expand Down
10 changes: 8 additions & 2 deletions lib/pagy/extras/items.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,15 @@ module Backend
def pagy_set_items_from_params(vars)
return if vars[:items] # :items explicitly set
return unless vars.key?(:items_extra) ? vars[:items_extra] : DEFAULT[:items_extra] # :items_extra is false
return unless (items = params[vars[:items_param] || DEFAULT[:items_param]]) # no items from request params
return unless (items_count = pagy_get_items_size(vars)) # no items from request params

vars[:items] = [items.to_i, vars.key?(:max_items) ? vars[:max_items] : DEFAULT[:max_items]].compact.min
vars[:items] = [items_count.to_i, vars.key?(:max_items) ? vars[:max_items] : DEFAULT[:max_items]].compact.min
end

# Get the items count from the params
# Overridable by the jsonapi extra
def pagy_get_items_size(vars)
params[vars[:items_param] || DEFAULT[:items_param]]
end
end

Expand Down
62 changes: 62 additions & 0 deletions lib/pagy/extras/jsonapi.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# See the Pagy documentation: https://ddnexus.github.io/pagy/docs/extras/jsonapi
# frozen_string_literal: true

require 'pagy/url_helpers'

class Pagy # :nodoc:
DEFAULT[:jsonapi] = true

# Add a specialized backend method compliant with JSON:API
module JsonApiExtra
# JsonApi :page param error
class ReservedParamError < StandardError
# Inform about the actual value
def initialize(value)
super("expected reserved :page param to be nil or Hash; got #{value.inspect}")
end
end

private

include UrlHelpers

# Return the jsonapi links
def pagy_jsonapi_links(pagy, **opts)
{ first: pagy_url_for(pagy, 1, **opts),
last: pagy_url_for(pagy, pagy.last, **opts),
prev: pagy_url_for(pagy, pagy.prev, **opts),
next: pagy_url_for(pagy, pagy.next, **opts) }
end

# Should skip the jsonapi
def pagy_skip_jsonapi?(vars)
return true if vars[:jsonapi] == false || (vars[:jsonapi].nil? && DEFAULT[:jsonapi] == false)
# check the reserved :page param and raise PageParamError or return nil
raise ReservedParamError, params[:page] unless params[:page].is_a?(Hash) || params[:page].nil?
end

# Override the Backend method
def pagy_get_page(vars)
return super if pagy_skip_jsonapi?(vars)

((params[:page].is_a?(Hash) && params[:page][vars[:page_param] || DEFAULT[:page_param]]) || 1).to_i
end

# Override the ItemsExtra::Backend method
def pagy_get_items_size(vars)
return super if pagy_skip_jsonapi?(vars)

params[:page][vars[:items_param] || DEFAULT[:items_param]] if params[:page].is_a?(Hash)
end

# Override UrlHelper method
def pagy_set_query_params(page, vars, params)
return super unless vars[:jsonapi]

params['page'] ||= {}
params['page'][vars[:page_param].to_s] = page
params['page'][vars[:items_param].to_s] = vars[:items] if vars[:items_extra]
end
end
Backend.prepend JsonApiExtra
end
2 changes: 1 addition & 1 deletion lib/pagy/extras/meilisearch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def pagy_meilisearch(pagy_search_args, vars = {})
def pagy_meilisearch_get_vars(_collection, vars)
pagy_set_items_from_params(vars) if defined?(ItemsExtra)
vars[:items] ||= DEFAULT[:items]
vars[:page] ||= (params[vars[:page_param] || DEFAULT[:page_param]] || 1).to_i
vars[:page] ||= pagy_get_page(vars)
vars
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/pagy/extras/searchkick.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def pagy_searchkick(pagy_search_args, vars = {})
def pagy_searchkick_get_vars(_collection, vars)
pagy_set_items_from_params(vars) if defined?(ItemsExtra)
vars[:items] ||= DEFAULT[:items]
vars[:page] ||= (params[vars[:page_param] || DEFAULT[:page_param]] || 1).to_i
vars[:page] ||= pagy_get_page(vars)
vars
end
end
Expand Down
15 changes: 6 additions & 9 deletions lib/pagy/extras/standalone.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,12 @@ def build_nested_query(value, prefix = nil)
def pagy_url_for(pagy, page, absolute: false, html_escaped: false, **_)
return super unless pagy.vars[:url]

vars = pagy.vars
page_param = vars[:page_param].to_s
items_param = vars[:items_param].to_s
params = pagy.params.is_a?(Hash) ? pagy.params.clone : {} # safe when it gets reused
params[page_param] = page
params[items_param] = vars[:items] if vars[:items_extra]
params = pagy.params.call(params) if pagy.params.is_a?(Proc)
query_string = "?#{QueryUtils.build_nested_query(params)}"
query_string = query_string.gsub('&', '&amp;') if html_escaped # the only unescaped entity
vars = pagy.vars
params = pagy.params.is_a?(Hash) ? pagy.params.clone : {} # safe when it gets reused
pagy_set_query_params(page, vars, params)
params = pagy.params.call(params) if pagy.params.is_a?(Proc)
query_string = "?#{QueryUtils.build_nested_query(params)}"
query_string = query_string.gsub('&', '&amp;') if html_escaped # the only unescaped entity
"#{vars[:url]}#{query_string}#{vars[:fragment]}"
end
end
Expand Down
26 changes: 15 additions & 11 deletions lib/pagy/url_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,22 @@ module UrlHelpers
# It supports all Rack-based frameworks (Sinatra, Padrino, Rails, ...).
# For non-rack environments you can use the standalone extra
def pagy_url_for(pagy, page, absolute: false, html_escaped: false, **_)
vars = pagy.vars
request_path = vars[:request_path].to_s.empty? ? request.path : vars[:request_path]
page_param = vars[:page_param].to_s
items_param = vars[:items_param].to_s
params = pagy.params.is_a?(Hash) ? pagy.params.transform_keys(&:to_s) : {}
params = request.GET.merge(params)
params[page_param] = page
params[items_param] = vars[:items] if vars[:items_extra]
params = pagy.params.call(params) if pagy.params.is_a?(Proc)
query_string = "?#{Rack::Utils.build_nested_query(params)}"
query_string = query_string.gsub('&', '&amp;') if html_escaped # the only unescaped entity
vars = pagy.vars
request_path = vars[:request_path].to_s.empty? ? request.path : vars[:request_path]
pagy_params = pagy.params.is_a?(Hash) ? pagy.params.transform_keys(&:to_s) : {}
params = request.GET.merge(pagy_params)
pagy_set_query_params(page, vars, params)
params = pagy.params.call(params) if pagy.params.is_a?(Proc)
query_string = "?#{Rack::Utils.build_nested_query(params)}"
query_string = query_string.gsub('&', '&amp;') if html_escaped # the only unescaped entity
"#{request.base_url if absolute}#{request_path}#{query_string}#{vars[:fragment]}"
end

# Add the page and items params
# Overridable by the jsonapi extra
def pagy_set_query_params(page, vars, params)
params[vars[:page_param].to_s] = page
params[vars[:items_param].to_s] = vars[:items] if vars[:items_extra]
end
end
end
1 change: 1 addition & 0 deletions pagy.manifest
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ lib/pagy/extras/gearbox.rb
lib/pagy/extras/headers.rb
lib/pagy/extras/i18n.rb
lib/pagy/extras/items.rb
lib/pagy/extras/jsonapi.rb
lib/pagy/extras/materialize.rb
lib/pagy/extras/meilisearch.rb
lib/pagy/extras/metadata.rb
Expand Down
Loading

0 comments on commit e35233b

Please sign in to comment.