From 950c67fb5d9ebc7c0562f476bacbf3c2390f2a88 Mon Sep 17 00:00:00 2001 From: Gareth du Plooy Date: Thu, 4 Jul 2019 12:41:58 -0500 Subject: [PATCH] Implements relative cursor pagination. --- lib/active_resource/collection_ext.rb | 7 + lib/shopify_api.rb | 2 + lib/shopify_api/collection_pagination.rb | 52 ++++++++ lib/shopify_api/pagination_link_headers.rb | 34 +++++ test/pagination_test.rb | 146 +++++++++++++++++++++ 5 files changed, 241 insertions(+) create mode 100644 lib/active_resource/collection_ext.rb create mode 100644 lib/shopify_api/collection_pagination.rb create mode 100644 lib/shopify_api/pagination_link_headers.rb create mode 100644 test/pagination_test.rb diff --git a/lib/active_resource/collection_ext.rb b/lib/active_resource/collection_ext.rb new file mode 100644 index 000000000..1e27588da --- /dev/null +++ b/lib/active_resource/collection_ext.rb @@ -0,0 +1,7 @@ +require 'shopify_api/collection_pagination' + +module ActiveResource + class Collection + prepend ShopifyAPI::CollectionPagination + end +end diff --git a/lib/shopify_api.rb b/lib/shopify_api.rb index 07ac6c899..c9b2ab42a 100644 --- a/lib/shopify_api.rb +++ b/lib/shopify_api.rb @@ -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 @@ -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 diff --git a/lib/shopify_api/collection_pagination.rb b/lib/shopify_api/collection_pagination.rb new file mode 100644 index 000000000..629a6e070 --- /dev/null +++ b/lib/shopify_api/collection_pagination.rb @@ -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::Release.new('2019-07') + + def fetch_page(page_info) + return [] unless page_info + + resource_class.where(original_params.merge(page_info: next_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)["page_info"][0] + end + + def pagination_link_headers + @pagination_link_headers ||= ShopifyAPI::PaginationLinkHeaders.new( + ShopifyAPI::Base.connection.response["Link"] + ) + end + end +end diff --git a/lib/shopify_api/pagination_link_headers.rb b/lib/shopify_api/pagination_link_headers.rb new file mode 100644 index 000000000..94005d194 --- /dev/null +++ b/lib/shopify_api/pagination_link_headers.rb @@ -0,0 +1,34 @@ +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 diff --git a/test/pagination_test.rb b/test/pagination_test.rb new file mode 100644 index 000000000..b5d48679e --- /dev/null +++ b/test/pagination_test.rb @@ -0,0 +1,146 @@ +require 'test_helper' + +class PaginationTest < Test::Unit::TestCase + def setup + super + + @version = ShopifyAPI::ApiVersion::Release.new('2019-07') + ShopifyAPI::Base.api_version = @version.to_s + @next_page_info = "eyJkaXJlY3Rpb24iOiJuZXh0IiwibGFzdF9pZCI6NDQwMDg5NDIzLCJsYXN0X3ZhbHVlIjoiNDQwMDg5NDIzIn0%3D" + @previous_page_info = "eyJsYXN0X2lkIjoxMDg4MjgzMDksImxhc3RfdmFsdWUiOiIxMDg4MjgzMDkiLCJkaXJlY3Rpb24iOiJuZXh0In0%3D" + + @next_link_header = "; rel=\"next\"" + @previous_link_header = "; rel=\"previous\"" + end + + test "navigates using next and previous link headers" do + link_header = + "; rel=\"previous\",\ + ; 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/2019-07/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/2019-07/orders.json?page_info=#{@previous_page_info}", + method: :get, + status: 200, + body: load_fixture('orders') + ) + + previous_page = orders.fetch_previous_page + assert_equal 450789469, next_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/2019-07/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/2019-07/orders.json?fields=id%2Cupdated_at&page_info=eyJkaXJlY3Rpb24iOiJuZXh0IiwibGFzdF9pZCI6NDQwMDg5NDIzLCJsYXN0X3ZhbHVlIjoiNDQwMDg5NDIzIn0%3D", + 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 = ";" + 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