Skip to content

Commit f16e479

Browse files
committed
Implements relative cursor pagination.
1 parent d0aeb03 commit f16e479

File tree

6 files changed

+309
-0
lines changed

6 files changed

+309
-0
lines changed

README.md

+30
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,36 @@ ActiveResource is threadsafe as of version 4.1 (which works with Rails 4.x and a
365365

366366
If you were previously using Shopify's [activeresource fork](https://github.com/shopify/activeresource) then you should remove it and use ActiveResource 4.1.
367367

368+
## Pagination
369+
370+
Pagination can occur in one of two ways.
371+
372+
Page based pagination
373+
```ruby
374+
page = 1
375+
products = ShopifyAPI::Product.find(:all, params: { limit: 50, page: page })
376+
process_products(products)
377+
while(products.count == 50)
378+
page += 1
379+
products = ShopifyAPI::Product.find(:all, params: { limit: 50, page: page })
380+
process_products(products)
381+
end
382+
```
383+
384+
Page based pagination will be deprecated in the `2019-10` API version, in favor of the second method of pagination:
385+
386+
[Relative cursor based pagination](https://help.shopify.com/en/api/guides/paginated-rest-results)
387+
```ruby
388+
products = ShopifyAPI::Product.find(:all, params: { limit: 50 })
389+
process_products(products)
390+
while products.next_page?
391+
products = products.fetch_next_page
392+
process_products(products)
393+
end
394+
```
395+
396+
Relative cursor pagination is currently available for all endpoints using the `unstable` API version.
397+
368398
## Using Development Version
369399

370400
Download the source code and run:

lib/active_resource/collection_ext.rb

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
require 'shopify_api/collection_pagination'
2+
3+
module ActiveResource
4+
class Collection
5+
prepend ShopifyAPI::CollectionPagination
6+
end
7+
end

lib/shopify_api.rb

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
require 'shopify_api/defined_versions'
1010
require 'shopify_api/api_version'
1111
require 'active_resource/json_errors'
12+
require 'active_resource/collection_ext'
1213
require 'shopify_api/disable_prefix_check'
1314

1415
module ShopifyAPI
@@ -21,6 +22,7 @@ module ShopifyAPI
2122
require 'shopify_api/resources'
2223
require 'shopify_api/session'
2324
require 'shopify_api/connection'
25+
require 'shopify_api/pagination_link_headers'
2426

2527
if ShopifyAPI::Base.respond_to?(:connection_class)
2628
ShopifyAPI::Base.connection_class = ShopifyAPI::Connection
+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
module ShopifyAPI
2+
module CollectionPagination
3+
4+
def initialize(args)
5+
@previous_url_params = extract_url_params(pagination_link_headers.previous_link)
6+
@next_url_params = extract_url_params(pagination_link_headers.next_link)
7+
super(args)
8+
end
9+
10+
def next_page?
11+
ensure_available
12+
@next_url_params.present?
13+
end
14+
15+
def previous_page?
16+
ensure_available
17+
@previous_url_params.present?
18+
end
19+
20+
def fetch_next_page
21+
fetch_page(@next_url_params)
22+
end
23+
24+
def fetch_previous_page
25+
fetch_page(@previous_url_params)
26+
end
27+
28+
private
29+
30+
AVAILABLE_IN_VERSION = ShopifyAPI::ApiVersion::Unstable.new
31+
32+
def fetch_page(url_params)
33+
ensure_available
34+
return [] unless url_params.present?
35+
36+
resource_class.where(url_params)
37+
end
38+
39+
def extract_url_params(link_header)
40+
return nil unless link_header.present?
41+
Rack::Utils.parse_nested_query(link_header.url.query)
42+
end
43+
44+
def pagination_link_headers
45+
@pagination_link_headers ||= ShopifyAPI::PaginationLinkHeaders.new(
46+
ShopifyAPI::Base.connection.response["Link"]
47+
)
48+
end
49+
50+
def ensure_available
51+
raise NotImplementedError unless ShopifyAPI::Base.api_version >= AVAILABLE_IN_VERSION
52+
end
53+
end
54+
end
+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
module ShopifyAPI
2+
class InvalidPaginationLinksError < StandardError; end
3+
4+
class PaginationLinkHeaders
5+
LinkHeader = Struct.new(:url, :rel)
6+
attr_reader :previous_link, :next_link
7+
8+
def initialize(link_header)
9+
links = parse_link_header(link_header)
10+
@previous_link = links.find { |link| link.rel == :previous }
11+
@next_link = links.find { |link| link.rel == :next }
12+
13+
self
14+
end
15+
16+
private
17+
18+
def parse_link_header(link_header)
19+
return [] unless link_header.present?
20+
links = link_header.split(',')
21+
links.map do |link|
22+
parts = link.split('; ')
23+
raise ShopifyAPI::InvalidPaginationLinksError.new("Invalid link header: url and rel expected") unless parts.length == 2
24+
25+
url = parts[0][/<(.*)>/, 1]
26+
rel = parts[1][/rel="(.*)"/, 1]&.to_sym
27+
28+
url = URI.parse(url)
29+
LinkHeader.new(url, rel)
30+
end
31+
end
32+
end
33+
end

test/pagination_test.rb

+183
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
require 'test_helper'
2+
3+
class PaginationTest < Test::Unit::TestCase
4+
def setup
5+
super
6+
7+
@version = ShopifyAPI::ApiVersion::Unstable.new
8+
ShopifyAPI::Base.api_version = @version.to_s
9+
@next_page_info = "eyJkaXJlY3Rpb24iOiJuZXh0IiwibGFzdF9pZCI6NDQwMDg5NDIzLCJsYXN0X3ZhbHVlIjoiNDQwMDg5NDIzIn0%3D"
10+
@previous_page_info = "eyJsYXN0X2lkIjoxMDg4MjgzMDksImxhc3RfdmFsdWUiOiIxMDg4MjgzMDkiLCJkaXJlY3Rpb24iOiJuZXh0In0%3D"
11+
12+
@next_link_header = "<https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?page_info=#{@next_page_info}>; rel=\"next\""
13+
@previous_link_header = "<https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?page_info=#{@previous_page_info}>; rel=\"previous\""
14+
end
15+
16+
test "navigates using next and previous link headers with no original params" do
17+
link_header ="#{@previous_link_header}, #{@next_link_header}"
18+
19+
fake 'orders', :method => :get, :status => 200, api_version: @version, :body => load_fixture('orders'), :link => link_header
20+
orders = ShopifyAPI::Order.all
21+
22+
fake(
23+
'orders',
24+
url: "https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?page_info=#{@next_page_info}",
25+
method: :get,
26+
status: 200,
27+
body: load_fixture('orders')
28+
)
29+
next_page = orders.fetch_next_page
30+
assert_equal 450789469, next_page.first.id
31+
32+
fake(
33+
'orders',
34+
url: "https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?page_info=#{@previous_page_info}",
35+
method: :get,
36+
status: 200,
37+
body: load_fixture('orders').gsub("450789469", "1122334455")
38+
)
39+
40+
previous_page = orders.fetch_previous_page
41+
assert_equal 1122334455, previous_page.first.id
42+
end
43+
44+
test "uses all passed in querystring parameters" do
45+
params = "page_info=#{@next_page_info}&limit=50&fields=#{CGI.escape('id,created_at')}"
46+
@next_link_header = "<https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?#{params}>; rel=\"next\""
47+
fake(
48+
'orders',
49+
method: :get,
50+
status: 200,
51+
api_version: @version,
52+
url: "https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?fields=id%2Cupdated_at&limit=100",
53+
body: load_fixture('orders'),
54+
link: @next_link_header
55+
)
56+
orders = ShopifyAPI::Order.where(fields: 'id,updated_at', limit: 100)
57+
58+
fake(
59+
'orders',
60+
method: :get,
61+
status: 200,
62+
api_version: @version,
63+
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}",
64+
body: load_fixture('orders')
65+
)
66+
next_page = orders.fetch_next_page
67+
assert_equal 450789469, next_page.first.id
68+
end
69+
70+
test "returns empty next page if just the previous page is present" do
71+
fake 'orders', :method => :get, :status => 200, api_version: @version, :body => load_fixture('orders'), :link => @previous_link_header
72+
orders = ShopifyAPI::Order.all
73+
74+
next_page = orders.fetch_next_page
75+
assert_empty next_page
76+
end
77+
78+
test "returns an empty previous page if just the next page is present" do
79+
fake 'orders', :method => :get, :status => 200, api_version: @version, :body => load_fixture('orders'), :link => @next_link_header
80+
orders = ShopifyAPI::Order.all
81+
82+
next_page = orders.fetch_previous_page
83+
assert_empty next_page
84+
end
85+
86+
test "#next_page? returns true if next page is present" do
87+
fake 'orders', :method => :get, :status => 200, api_version: @version, :body => load_fixture('orders'), :link => @next_link_header
88+
orders = ShopifyAPI::Order.all
89+
90+
assert orders.next_page?
91+
end
92+
93+
test "#next_page? returns false if next page is not present" do
94+
fake 'orders', :method => :get, :status => 200, api_version: @version, :body => load_fixture('orders'), :link => @previous_link_header
95+
orders = ShopifyAPI::Order.all
96+
97+
refute orders.next_page?
98+
end
99+
100+
test "#previous_page? returns true if previous page is present" do
101+
fake 'orders', :method => :get, :status => 200, api_version: @version, :body => load_fixture('orders'), :link => @previous_link_header
102+
orders = ShopifyAPI::Order.all
103+
104+
assert orders.previous_page?
105+
end
106+
107+
test "#previous_page? returns false if next page is not present" do
108+
fake 'orders', :method => :get, :status => 200, api_version: @version, :body => load_fixture('orders'), :link => @next_link_header
109+
orders = ShopifyAPI::Order.all
110+
111+
refute orders.previous_page?
112+
end
113+
114+
test "pagination handles no link headers" do
115+
fake 'orders', :method => :get, :status => 200, api_version: @version, :body => load_fixture('orders')
116+
orders = ShopifyAPI::Order.all
117+
118+
refute orders.next_page?
119+
refute orders.previous_page?
120+
assert_empty orders.fetch_next_page
121+
assert_empty orders.fetch_previous_page
122+
end
123+
124+
test "raises on invalid pagination links" do
125+
link_header = "<https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?page_info=#{@next_page_info}>;"
126+
fake 'orders', :method => :get, :status => 200, api_version: @version, :body => load_fixture('orders'), :link => link_header
127+
128+
assert_raises ShopifyAPI::InvalidPaginationLinksError do
129+
ShopifyAPI::Order.all
130+
end
131+
end
132+
133+
test "raises on an invalid API version" do
134+
version = ShopifyAPI::ApiVersion::Release.new('2019-04')
135+
ShopifyAPI::Base.api_version = version.to_s
136+
137+
fake 'orders', :method => :get, :status => 200, api_version: version, :body => load_fixture('orders')
138+
orders = ShopifyAPI::Order.all
139+
140+
assert_raises NotImplementedError do
141+
orders.fetch_next_page
142+
end
143+
end
144+
145+
test "allows for multiple concurrent API collection objects" do
146+
first_request_params = "page_info=#{@next_page_info}&limit=5"
147+
fake(
148+
'orders',
149+
method: :get,
150+
status: 200,
151+
api_version: @version,
152+
url: "https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?limit=5",
153+
body: load_fixture('orders'),
154+
link: "<https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?#{first_request_params}>; rel=\"next\""
155+
)
156+
orders = ShopifyAPI::Order.where(limit: 5)
157+
158+
second_request_params = "page_info=#{@next_page_info}&limit=5"
159+
fake(
160+
'orders',
161+
method: :get,
162+
status: 200,
163+
api_version: @version,
164+
url: "https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?limit=10",
165+
body: load_fixture('orders'),
166+
link: "<https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?#{second_request_params}>; rel=\"next\""
167+
)
168+
169+
orders2 = ShopifyAPI::Order.where(limit: 10)
170+
171+
fake(
172+
'orders',
173+
method: :get,
174+
status: 200,
175+
api_version: @version,
176+
url: "https://this-is-my-test-shop.myshopify.com/admin/api/unstable/orders.json?limit=5&page_info=#{@next_page_info}",
177+
body: load_fixture('orders')
178+
)
179+
next_page = orders.fetch_next_page
180+
assert_equal 450789469, next_page.first.id
181+
end
182+
183+
end

0 commit comments

Comments
 (0)