Browse Source

Implements relative cursor pagination.

Gareth du Plooy 5 years ago
parent
commit
f16e479d13

+ 30 - 0
README.md

@@ -365,6 +365,36 @@ ActiveResource is threadsafe as of version 4.1 (which works with Rails 4.x and a
 
 If you were previously using Shopify's [activeresource fork](https://github.com/shopify/activeresource) then you should remove it and use ActiveResource 4.1.
 
+## Pagination
+
+Pagination can occur in one of two ways.
+
+Page based pagination
+```ruby
+page = 1
+products = ShopifyAPI::Product.find(:all, params: { limit: 50, page: page })
+process_products(products)
+while(products.count == 50)
+  page += 1
+  products = ShopifyAPI::Product.find(:all, params: { limit: 50, page: page })
+  process_products(products)
+end
+```
+
+Page based pagination will be deprecated in the `2019-10` API version, in favor of the second method of pagination:
+
+[Relative cursor based pagination](https://help.shopify.com/en/api/guides/paginated-rest-results)
+```ruby
+products = ShopifyAPI::Product.find(:all, params: { limit: 50 })
+process_products(products)
+while products.next_page?
+  products = products.fetch_next_page
+  process_products(products)
+end
+```
+
+Relative cursor pagination is currently available for all endpoints using the `unstable` API version.
+
 ## Using Development Version
 
 Download the source code and run:

+ 7 - 0
lib/active_resource/collection_ext.rb

@@ -0,0 +1,7 @@
+require 'shopify_api/collection_pagination'
+
+module ActiveResource
+  class Collection
+    prepend ShopifyAPI::CollectionPagination
+  end
+end

+ 2 - 0
lib/shopify_api.rb

@@ -9,6 +9,7 @@ require 'shopify_api/limits'
 require 'shopify_api/defined_versions'
 require 'shopify_api/api_version'
 require 'active_resource/json_errors'
+require 'active_resource/collection_ext'
 require 'shopify_api/disable_prefix_check'
 
 module ShopifyAPI
@@ -21,6 +22,7 @@ require 'shopify_api/countable'
 require 'shopify_api/resources'
 require 'shopify_api/session'
 require 'shopify_api/connection'
+require 'shopify_api/pagination_link_headers'
 
 if ShopifyAPI::Base.respond_to?(:connection_class)
   ShopifyAPI::Base.connection_class = ShopifyAPI::Connection

+ 54 - 0
lib/shopify_api/collection_pagination.rb

@@ -0,0 +1,54 @@
+module ShopifyAPI
+  module CollectionPagination
+
+    def initialize(args)
+      @previous_url_params = extract_url_params(pagination_link_headers.previous_link)
+      @next_url_params = extract_url_params(pagination_link_headers.next_link)
+      super(args)
+    end
+
+    def next_page?
+      ensure_available
+      @next_url_params.present?
+    end
+
+    def previous_page?
+      ensure_available
+      @previous_url_params.present?
+    end
+
+    def fetch_next_page
+      fetch_page(@next_url_params)
+    end
+
+    def fetch_previous_page
+      fetch_page(@previous_url_params)
+    end
+
+    private
+
+    AVAILABLE_IN_VERSION = ShopifyAPI::ApiVersion::Unstable.new
+
+    def fetch_page(url_params)
+      ensure_available
+      return [] unless url_params.present?
+
+      resource_class.where(url_params)
+    end
+
+    def extract_url_params(link_header)
+      return nil unless link_header.present?
+      Rack::Utils.parse_nested_query(link_header.url.query)
+    end
+
+    def pagination_link_headers
+      @pagination_link_headers ||= ShopifyAPI::PaginationLinkHeaders.new(
+        ShopifyAPI::Base.connection.response["Link"]
+      )
+    end
+
+    def ensure_available
+      raise NotImplementedError unless ShopifyAPI::Base.api_version >= AVAILABLE_IN_VERSION
+    end
+  end
+end

+ 33 - 0
lib/shopify_api/pagination_link_headers.rb

@@ -0,0 +1,33 @@
+module ShopifyAPI
+  class InvalidPaginationLinksError < StandardError; end
+
+  class PaginationLinkHeaders
+    LinkHeader = Struct.new(:url, :rel)
+    attr_reader :previous_link, :next_link
+
+    def initialize(link_header)
+      links = parse_link_header(link_header)
+      @previous_link = links.find { |link| link.rel == :previous }
+      @next_link = links.find { |link| link.rel == :next }
+
+      self
+    end
+
+    private
+
+    def parse_link_header(link_header)
+      return [] unless link_header.present?
+      links = link_header.split(',')
+      links.map do |link|
+        parts = link.split('; ')
+        raise ShopifyAPI::InvalidPaginationLinksError.new("Invalid link header: url and rel expected") unless parts.length == 2
+
+        url = parts[0][/<(.*)>/, 1]
+        rel = parts[1][/rel="(.*)"/, 1]&.to_sym
+
+        url = URI.parse(url)
+        LinkHeader.new(url, rel)
+      end
+    end
+  end
+end

+ 183 - 0
test/pagination_test.rb

@@ -0,0 +1,183 @@
+require 'test_helper'
+
+class PaginationTest < Test::Unit::TestCase
+  def setup
+    super
+
+    @version = ShopifyAPI::ApiVersion::Unstable.new
+    ShopifyAPI::Base.api_version = @version.to_s
+    @next_page_info = "eyJkaXJlY3Rpb24iOiJuZXh0IiwibGFzdF9pZCI6NDQwMDg5NDIzLCJsYXN0X3ZhbHVlIjoiNDQwMDg5NDIzIn0%3D"
+    @previous_page_info = "eyJsYXN0X2lkIjoxMDg4MjgzMDksImxhc3RfdmFsdWUiOiIxMDg4MjgzMDkiLCJkaXJlY3Rpb24iOiJuZXh0In0%3D"
+
+    @next_link_header = "<https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?page_info=#{@next_page_info}>; rel=\"next\""
+    @previous_link_header = "<https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?page_info=#{@previous_page_info}>; rel=\"previous\""
+  end
+
+  test "navigates using next and previous link headers with no original params" do
+    link_header ="#{@previous_link_header}, #{@next_link_header}"
+
+    fake 'orders', :method => :get, :status => 200, api_version: @version, :body => load_fixture('orders'), :link => link_header
+    orders = ShopifyAPI::Order.all
+
+    fake(
+      'orders',
+      url: "https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?page_info=#{@next_page_info}",
+      method: :get,
+      status: 200,
+      body: load_fixture('orders')
+    )
+    next_page = orders.fetch_next_page
+    assert_equal 450789469, next_page.first.id
+
+    fake(
+      'orders',
+      url: "https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?page_info=#{@previous_page_info}",
+      method: :get,
+      status: 200,
+      body: load_fixture('orders').gsub("450789469", "1122334455")
+    )
+
+    previous_page = orders.fetch_previous_page
+    assert_equal 1122334455, previous_page.first.id
+  end
+
+  test "uses all passed in querystring parameters" do
+    params = "page_info=#{@next_page_info}&limit=50&fields=#{CGI.escape('id,created_at')}"
+    @next_link_header = "<https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?#{params}>; rel=\"next\""
+    fake(
+      'orders',
+      method: :get,
+      status: 200,
+      api_version: @version,
+      url: "https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?fields=id%2Cupdated_at&limit=100",
+      body: load_fixture('orders'),
+      link: @next_link_header
+    )
+    orders = ShopifyAPI::Order.where(fields: 'id,updated_at', limit: 100)
+
+    fake(
+      'orders',
+      method: :get,
+      status: 200,
+      api_version: @version,
+      url: "https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?fields=id%2Ccreated_at&limit=50&page_info=#{@next_page_info}",
+      body: load_fixture('orders')
+    )
+    next_page = orders.fetch_next_page
+    assert_equal 450789469, next_page.first.id
+  end
+
+  test "returns empty next page if just the previous page is present" do
+    fake 'orders', :method => :get, :status => 200, api_version: @version, :body => load_fixture('orders'), :link => @previous_link_header
+    orders = ShopifyAPI::Order.all
+
+    next_page = orders.fetch_next_page
+    assert_empty next_page
+  end
+
+  test "returns an empty previous page if just the next page is present" do
+    fake 'orders', :method => :get, :status => 200, api_version: @version, :body => load_fixture('orders'), :link => @next_link_header
+    orders = ShopifyAPI::Order.all
+
+    next_page = orders.fetch_previous_page
+    assert_empty next_page
+  end
+
+  test "#next_page? returns true if next page is present" do
+    fake 'orders', :method => :get, :status => 200, api_version: @version, :body => load_fixture('orders'), :link => @next_link_header
+    orders = ShopifyAPI::Order.all
+
+    assert orders.next_page?
+  end
+
+  test "#next_page? returns false if next page is not present" do
+    fake 'orders', :method => :get, :status => 200, api_version: @version, :body => load_fixture('orders'), :link => @previous_link_header
+    orders = ShopifyAPI::Order.all
+
+    refute orders.next_page?
+  end
+
+  test "#previous_page? returns true if previous page is present" do
+    fake 'orders', :method => :get, :status => 200, api_version: @version, :body => load_fixture('orders'), :link => @previous_link_header
+    orders = ShopifyAPI::Order.all
+
+    assert orders.previous_page?
+  end
+
+  test "#previous_page? returns false if next page is not present" do
+    fake 'orders', :method => :get, :status => 200, api_version: @version, :body => load_fixture('orders'), :link => @next_link_header
+    orders = ShopifyAPI::Order.all
+
+    refute orders.previous_page?
+  end
+
+  test "pagination handles no link headers" do
+    fake 'orders', :method => :get, :status => 200, api_version: @version, :body => load_fixture('orders')
+    orders = ShopifyAPI::Order.all
+
+    refute orders.next_page?
+    refute orders.previous_page?
+    assert_empty orders.fetch_next_page
+    assert_empty orders.fetch_previous_page
+  end
+
+  test "raises on invalid pagination links" do
+    link_header = "<https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?page_info=#{@next_page_info}>;"
+    fake 'orders', :method => :get, :status => 200, api_version: @version, :body => load_fixture('orders'), :link => link_header
+
+    assert_raises ShopifyAPI::InvalidPaginationLinksError do
+      ShopifyAPI::Order.all
+    end
+  end
+
+  test "raises on an invalid API version" do
+    version = ShopifyAPI::ApiVersion::Release.new('2019-04')
+    ShopifyAPI::Base.api_version = version.to_s
+
+    fake 'orders', :method => :get, :status => 200, api_version: version, :body => load_fixture('orders')
+    orders = ShopifyAPI::Order.all
+
+    assert_raises NotImplementedError do
+      orders.fetch_next_page
+    end
+  end
+
+  test "allows for multiple concurrent API collection objects" do
+    first_request_params = "page_info=#{@next_page_info}&limit=5"
+    fake(
+      'orders',
+      method: :get,
+      status: 200,
+      api_version: @version,
+      url: "https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?limit=5",
+      body: load_fixture('orders'),
+      link: "<https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?#{first_request_params}>; rel=\"next\""
+    )
+    orders = ShopifyAPI::Order.where(limit: 5)
+
+    second_request_params = "page_info=#{@next_page_info}&limit=5"
+    fake(
+      'orders',
+      method: :get,
+      status: 200,
+      api_version: @version,
+      url: "https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?limit=10",
+      body: load_fixture('orders'),
+      link: "<https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?#{second_request_params}>; rel=\"next\""
+    )
+
+    orders2 = ShopifyAPI::Order.where(limit: 10)
+
+    fake(
+      'orders',
+      method: :get,
+      status: 200,
+      api_version: @version,
+      url: "https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?limit=5&page_info=#{@next_page_info}",
+      body: load_fixture('orders')
+    )
+    next_page = orders.fetch_next_page
+    assert_equal 450789469, next_page.first.id
+  end
+
+end