From 1bcb05f4796a8dee0140eb742987d053044a923b Mon Sep 17 00:00:00 2001
From: Domizio Demichelis
Date: Sat, 6 Jan 2024 21:11:43 +0700
Subject: [PATCH] JSON:API specifications
---
docs/api/jsonapi.md | 47 ++++++++++++++++++++++++++++++++
docs/api/pagy.md | 55 +++++++++++++++++++-------------------
lib/config/pagy.rb | 17 ++++++------
lib/pagy.rb | 30 ++++++++++-----------
lib/pagy/backend.rb | 7 ++++-
lib/pagy/extras/items.rb | 9 ++++++-
lib/pagy/extras/jsonapi.rb | 25 +++++++++++++++++
lib/pagy/url_helpers.rb | 27 +++++++++++--------
8 files changed, 154 insertions(+), 63 deletions(-)
create mode 100644 docs/api/jsonapi.md
create mode 100644 lib/pagy/extras/jsonapi.rb
diff --git a/docs/api/jsonapi.md b/docs/api/jsonapi.md
new file mode 100644
index 000000000..3dd02961d
--- /dev/null
+++ b/docs/api/jsonapi.md
@@ -0,0 +1,47 @@
+---
+title: JSON:API
+image: null
+---
+
+# JSON:API
+
+Pagy can be `JSON:API` compliant by just setting the `:jsonapi` variable to true. 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[page]=2&page[items]=30`.
+
+## Synopsis
+
+||| pagy.rb (initializer)
+```ruby
+require 'pagy/extras/jsonapi'
+# optionally disable it by default (opt-in)
+Pagy::DEFAULT[:jsonapi] = false
+# optional but useful extras
+require 'pagy/extras/items'
+require 'pagy/extras/metadata'
+```
+|||
+
+||| Controller
+```ruby
+# enable/disable on single object
+@pagy, @items = pagy(collection, jsonapi: true)
+# optionl/custom setup
+@pagy, @items = 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]
+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&...'}
+```
+|||
+
+## Interaction with extras
+
+If you want to allow your JSON:API app to allow the client to request a specific number of items per page and capping the request to a max number you can use the [items extra](/docs/extras/items.md).
+
+The [metadata extra]((/docs/extras/metadata.md)) implements also the `pagy_jsonapi_links` method returning the link hash (https://jsonapi.org/format/#fetching-pagination)
+
+An app that implements a JSON:API, if wants to allow the client to request a specific number of items per page, also capping the max number requested by the client.
+
diff --git a/docs/api/pagy.md b/docs/api/pagy.md
index d2036722c..2dc4093ee 100644
--- a/docs/api/pagy.md
+++ b/docs/api/pagy.md
@@ -98,8 +98,8 @@ Experimental: Its only function in the `Pagy` class is supporting the API of var
==- `label_for(page)`
Experimental: Its only function in the `Pagy` class is supporting the API of various frontend methods that require labelling for `Pagy::Calendar` instances. It returns the page label that will get displayed in the helpers/templates.
-===
+===
## Variables
@@ -125,16 +125,17 @@ They are all integers:
### Other Variables
-| Variable | Description | Default |
-|:--------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------------|
-| `:size` | The size of the page links to show: array of initial pages, before current page, after current page, final pages. _(see also [How to control the page links](/docs/how-to.md#control-the-page-links))_ | `[1,4,4,1]` |
-| `:page_param` | The name of the page param name used in the url. _(see [How to customize the page param](/docs/how-to.md#customize-the-page-param))_ | `:page` |
-| `:params` | It can be a `Hash` of params to add to the URL, or a `Proc` that can edit/add/delete the request params _(see [How to customize the params](/docs/how-to.md#customize-the-params))_ | `{}` |
-| `:fragment` | The arbitrary fragment string (including the "#") to add to the url. _(see [How to customize the params](/docs/how-to.md#customize-the-params))_ | `""` |
-| `:link_extra` | The extra attributes string (formatted as a valid HTML attribute/value pairs) added to the page links _(see [How to customize the link attributes](/docs/how-to.md#customize-the-link-attributes))_ | `""` |
-| `:i18n_key` | The i18n key to lookup the `item_name` that gets interpolated in a few helper outputs (see [How to customize the item name](/docs/how-to.md#customize-the-item-name)) | `"pagy.item_name"` |
-| `:cycle` | Enable cycling/circular/infinite pagination: `true` sets `next` to `1` when the current page is the last page | `false` |
-| `:request_path` | Allows overriding the request path for pagination links. If left blank, helpers will use `request.path`. NB: Do not pass in a full URL, but the path: For example, given `https://ddnexus.github.io/pagy/docs/api/pagy/` the path to be passed in is: `pagy/docs/api/pagy/`. (See: [Customize the request path](/docs/how-to.md#customize-the-request-path) )| `request.path` |
+| Variable | Description | Default |
+|:----------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------------|
+| `:size` | The size of the page links to show: array of initial pages, before current page, after current page, final pages. _(see also [How to control the page links](/docs/how-to.md#control-the-page-links))_ | `[1,4,4,1]` |
+| `:page_param` | The name of the page param name used in the url. _(see [How to customize the page param](/docs/how-to.md#customize-the-page-param))_ | `:page` |
+| `:params` | It can be a `Hash` of params to add to the URL, or a `Proc` that can edit/add/delete the request params _(see [How to customize the params](/docs/how-to.md#customize-the-params))_ | `{}` |
+| `:fragment` | The arbitrary fragment string (including the "#") to add to the url. _(see [How to customize the params](/docs/how-to.md#customize-the-params))_ | `""` |
+| `:link_extra` | The extra attributes string (formatted as a valid HTML attribute/value pairs) added to the page links _(see [How to customize the link attributes](/docs/how-to.md#customize-the-link-attributes))_ | `""` |
+| `:i18n_key` | The i18n key to lookup the `item_name` that gets interpolated in a few helper outputs (see [How to customize the item name](/docs/how-to.md#customize-the-item-name)) | `"pagy.item_name"` |
+| `:cycle` | Enable cycling/circular/infinite pagination: `true` sets `next` to `1` when the current page is the last page | `false` |
+| `:request_path` | Allows overriding the request path for pagination links. If left blank, helpers will use `request.path`. NB: Do not pass in a full URL, but the path: For example, given `https://ddnexus.github.io/pagy/docs/api/pagy/` the path to be passed in is: `pagy/docs/api/pagy/`. (See: [Customize the request path](/docs/how-to.md#customize-the-request-path) ) | `request.path` |
+| `jsonapi` | Enable `jsonapi` compliance of the pagy query params | `false` |
There is no specific validation for non-instance variables.
@@ -142,22 +143,22 @@ There is no specific validation for non-instance variables.
Pagy exposes all the instance variables needed for the pagination through a few attribute readers. They all return integers (or `nil`), except the `vars` hash:
-| Reader | Description |
-|:---------|:-------------------------------------------------------------------------------------------------------------------|
-| `count` | The collection `:count` |
-| `page` | The current page number |
-| `items` | The requested number of items for the page |
-| `pages` | The number of total pages in the collection (same as `last` but with cardinal meaning) |
-| `in` | The number of the items in the page |
-| `last` | The number of the last page in the collection (same as `pages` but with ordinal meaning) |
-| `offset` | The number of items skipped from the collection in order to get the start of the current page (`:outset` included) |
-| `from` | The collection-position of the first item in the page (`:outset` excluded) |
-| `to` | The collection-position of the last item in the page (`:outset` excluded) |
-| `prev` | The previous page number or `nil` if there is no previous page |
-| `next` | The next page number or `nil` if there is no next page |
-| `vars` | The variables hash |
-| `params` | The `:params` variable (`Hash` or `Proc`) |
-| `request_path` | The request path used for pagination helpers. If blank, helpers will use `request.path` |
+| Reader | Description |
+|:---------------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `count` | The collection `:count` |
+| `page` | The current page number |
+| `items` | The requested number of items for the page |
+| `pages` | The number of total pages in the collection (same as `last` but with cardinal meaning) |
+| `in` | The number of the items in the page |
+| `last` | The number of the last page in the collection (same as `pages` but with ordinal meaning) |
+| `offset` | The number of items skipped from the collection in order to get the start of the current page (`:outset` included) |
+| `from` | The collection-position of the first item in the page (`:outset` excluded) |
+| `to` | The collection-position of the last item in the page (`:outset` excluded) |
+| `prev` | The previous page number or `nil` if there is no previous page |
+| `next` | The next page number or `nil` if there is no next page |
+| `vars` | The variables hash |
+| `params` | The `:params` variable (`Hash` or `Proc`) |
+| `request_path` | The request path used for pagination helpers. If blank, helpers will use `request.path` |
### Lowest limit analysis
diff --git a/lib/config/pagy.rb b/lib/config/pagy.rb
index 20f74e3a2..28aa6d21c 100644
--- a/lib/config/pagy.rb
+++ b/lib/config/pagy.rb
@@ -20,15 +20,16 @@
# 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
+# Pagy::DEFAULT[:jsonapi] = true # example
# Extras
diff --git a/lib/pagy.rb b/lib/pagy.rb
index 754d5f8bd..60676a597 100644
--- a/lib/pagy.rb
+++ b/lib/pagy.rb
@@ -13,16 +13,16 @@ def self.root
end
# Default core vars: constant for easy access, but mutable for customizable defaults
- DEFAULT = { page: 1, # rubocop:disable Style/MutableConstant
- items: 20,
- outset: 0,
- size: [1, 4, 4, 1],
- page_param: :page,
- params: {},
- fragment: '',
- link_extra: '',
- i18n_key: 'pagy.item_name',
- cycle: false,
+ DEFAULT = { page: 1, # rubocop:disable Style/MutableConstant
+ items: 20,
+ outset: 0,
+ size: [1, 4, 4, 1],
+ page_param: :page,
+ params: {},
+ fragment: '',
+ link_extra: '',
+ i18n_key: 'pagy.item_name',
+ cycle: false,
request_path: '' }
attr_reader :count, :page, :items, :vars, :pages, :last, :offset, :in, :from, :to, :prev, :next, :params, :request_path
@@ -38,11 +38,11 @@ def initialize(vars)
setup_request_path_var
raise OverflowError.new(self, :page, "in 1..#{@last}", @page) if @page > @last
- @from = [@offset - @outset + 1, @count].min
- @to = [@offset - @outset + @items, @count].min
- @in = [@to - @from + 1, @count].min
- @prev = (@page - 1 unless @page == 1)
- @next = @page == @last ? (1 if @vars[:cycle]) : @page + 1
+ @from = [@offset - @outset + 1, @count].min
+ @to = [@offset - @outset + @items, @count].min
+ @in = [@to - @from + 1, @count].min
+ @prev = (@page - 1 unless @page == 1)
+ @next = @page == @last ? (1 if @vars[:cycle]) : @page + 1
end
# Return the array of page numbers and :gap items e.g. [1, :gap, 7, 8, "9", 10, 11, :gap, 36]
diff --git a/lib/pagy/backend.rb b/lib/pagy/backend.rb
index 151f6f1ba..08bf493ce 100644
--- a/lib/pagy/backend.rb
+++ b/lib/pagy/backend.rb
@@ -19,7 +19,12 @@ 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]]
+ page_param = vars[:page_param] || DEFAULT[:page_param]
+ vars[:page] ||= if defined?(JsonApiExtra) && vars.key?(:jsonapi) ? vars[:jsonapi] : DEFAULT[:jsonapi]
+ params[:page][page_param]
+ else
+ params[page_param]
+ end
vars
end
diff --git a/lib/pagy/extras/items.rb b/lib/pagy/extras/items.rb
index b44b83e61..865f62087 100644
--- a/lib/pagy/extras/items.rb
+++ b/lib/pagy/extras/items.rb
@@ -18,7 +18,14 @@ 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
+
+ items_param = vars[:items_param] || DEFAULT[:items_param]
+ items = if defined?(JsonApiExtra) && vars.key?(:jsonapi) ? vars[:jsonapi] : DEFAULT[:jsonapi]
+ params[:page][items_param]
+ else
+ params[items_param]
+ end
+ return unless items # no items from request params
vars[:items] = [items.to_i, vars.key?(:max_items) ? vars[:max_items] : DEFAULT[:max_items]].compact.min
end
diff --git a/lib/pagy/extras/jsonapi.rb b/lib/pagy/extras/jsonapi.rb
new file mode 100644
index 000000000..99d97c348
--- /dev/null
+++ b/lib/pagy/extras/jsonapi.rb
@@ -0,0 +1,25 @@
+# See the Pagy documentation: https://ddnexus.github.io/pagy/docs/extras/metadata
+# 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
+ private
+
+ include UrlHelpers
+
+ # Return the jsonapi links
+ def pagy_jsonapi_links(pagy)
+ { first: pagy_url_for(pagy, 1 , absolute: true),
+ last: pagy_url_for(pagy, pagy.last, absolute: true),
+ prev: pagy_url_for(pagy, pagy.prev, absolute: true),
+ next: pagy_url_for(pagy, pagy.next, absolute: true) }
+ end
+ end
+ Backend.prepend JsonApiExtra
+end
+
diff --git a/lib/pagy/url_helpers.rb b/lib/pagy/url_helpers.rb
index 0cfe728e2..d44157af5 100644
--- a/lib/pagy/url_helpers.rb
+++ b/lib/pagy/url_helpers.rb
@@ -7,17 +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('&', '&') 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)
+ page_param = vars[:page_param].to_s
+ items_param = vars[:items_param].to_s
+ if vars[:jsonapi]
+ params['page'][page_param] = page
+ params['page'][items_param] = vars[:items] if vars[:items_extra]
+ else
+ params[page_param] = page
+ params[items_param] = vars[:items] if vars[:items_extra]
+ end
+ params = pagy.params.call(params) if pagy.params.is_a?(Proc)
+ query_string = "?#{Rack::Utils.build_nested_query(params)}"
+ query_string = query_string.gsub('&', '&') if html_escaped # the only unescaped entity
"#{request.base_url if absolute}#{request_path}#{query_string}#{vars[:fragment]}"
end
end