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