Skip to content

Commit

Permalink
Implements relative cursor pagination.
Browse files Browse the repository at this point in the history
  • Loading branch information
garethson committed Jul 8, 2019
1 parent e5d7e01 commit f2c05be
Show file tree
Hide file tree
Showing 5 changed files with 240 additions and 0 deletions.
7 changes: 7 additions & 0 deletions lib/active_resource/collection_ext.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
require 'shopify_api/collection_pagination'

module ActiveResource
class Collection
prepend ShopifyAPI::CollectionPagination
end
end
2 changes: 2 additions & 0 deletions lib/shopify_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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
Expand All @@ -21,6 +22,7 @@ module ShopifyAPI
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
Expand Down
52 changes: 52 additions & 0 deletions lib/shopify_api/collection_pagination.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
module ShopifyAPI
module CollectionPagination

def next_page?
next_page_info.present?
end

def previous_page?
previous_page_info.present?
end

def fetch_next_page
fetch_page(next_page_info)
end

def fetch_previous_page
fetch_page(previous_page_info)
end

private

AVAILABLE_IN_VERSION = ShopifyAPI::ApiVersion::Unstable.new

def fetch_page(page_info)
return [] unless page_info

resource_class.where(original_params.merge(page_info: page_info))
end

def previous_page_info
@previous_page_info ||= extract_page_info(pagination_link_headers.previous_link)
end

def next_page_info
@next_page_info ||= extract_page_info(pagination_link_headers.next_link)
end

def extract_page_info(link_header)
raise NotImplementedError unless ShopifyAPI::Base.api_version >= AVAILABLE_IN_VERSION

return nil unless link_header.present?

CGI.parse(link_header.url.query).dig("page_info", 0)
end

def pagination_link_headers
@pagination_link_headers ||= ShopifyAPI::PaginationLinkHeaders.new(
ShopifyAPI::Base.connection.response["Link"]
)
end
end
end
33 changes: 33 additions & 0 deletions lib/shopify_api/pagination_link_headers.rb
Original file line number Diff line number Diff line change
@@ -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
146 changes: 146 additions & 0 deletions test/pagination_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
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" do
link_header =
"<https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?page_info=#{@previous_page_info}>; rel=\"previous\",\
<https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?page_info=#{@next_page_info}>; rel=\"next\""

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 "retains previous querystring parameters" do
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",
body: load_fixture('orders'),
link: @next_link_header
)
orders = ShopifyAPI::Order.where(fields: 'id,updated_at')

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&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
orders = ShopifyAPI::Order.all

assert_raises ShopifyAPI::InvalidPaginationLinksError do
orders.fetch_next_page
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
end

0 comments on commit f2c05be

Please sign in to comment.