-
Notifications
You must be signed in to change notification settings - Fork 56
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Shopify.Pagination module #92
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
defmodule Shopify.Enumerable do | ||
@moduledoc false | ||
|
||
alias Shopify.{Error, Pagination, Response} | ||
|
||
@enforce_keys [:data, :func, :middleware, :params, :session] | ||
defstruct @enforce_keys | ||
|
||
defimpl Enumerable do | ||
# See https://hexdocs.pm/elixir/Enumerable.html | ||
|
||
def count(_), do: {:error, __MODULE__} | ||
def member?(_, _), do: {:error, __MODULE__} | ||
def slice(_), do: {:error, __MODULE__} | ||
|
||
def reduce(%Shopify.Enumerable{}, {:halt, acc}, _fun), do: {:halted, acc} | ||
|
||
def reduce(%Shopify.Enumerable{} = enum, {:suspend, acc}, fun) do | ||
{:suspended, acc, &reduce(enum, &1, fun)} | ||
end | ||
|
||
def reduce(%Shopify.Enumerable{data: [], params: nil}, {:cont, acc}, _fun) do | ||
{:done, acc} | ||
end | ||
|
||
def reduce(%Shopify.Enumerable{data: []} = enum, {:cont, acc}, fun) do | ||
case enum.middleware.(fn -> enum.func.(enum.session, enum.params) end) do | ||
{:ok, %Response{data: data} = resp} -> | ||
reduce( | ||
%{enum | data: data, params: Pagination.next_page_params(resp)}, | ||
{:cont, acc}, | ||
fun | ||
) | ||
|
||
{:error, %Error{} = error} -> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here is a 3rd case that needs to be considered:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks, I'll look into this. I have a busy week ahead of me so there's a chance I won't get to it until next Monday. |
||
raise error | ||
end | ||
end | ||
|
||
def reduce(%Shopify.Enumerable{data: [head | tail]} = enum, {:cont, acc}, fun) do | ||
reduce(%{enum | data: tail}, fun.(head, acc), fun) | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,112 @@ | ||
defmodule Shopify.Pagination do | ||
@moduledoc """ | ||
Functions for performing cursor-based pagination. API versions prior to `"2019-10"` do not | ||
support cursor-based pagination on all endpoints. See [Shopify's | ||
documentation](https://help.shopify.com/en/api/guides/paginated-rest-results) for details. | ||
""" | ||
|
||
alias Shopify.Response | ||
|
||
@doc """ | ||
Returns an enumerable for iterating over Shopify items. In particular, this enumerable can be | ||
used with any of the functions in the `Enum` or `Stream` modules. The enumerable is lazy; no | ||
requests are made until iteration begins. It will automatically load additional pages of data as | ||
needed. | ||
|
||
If an error occurs while fetching a page of data then `Shopify.Error` will be raised. | ||
|
||
## Arguments | ||
|
||
* `session` - A `Shopify.Session` struct. See `Shopify.session/0`. | ||
* `func` - Function with an arity of 2 that accepts a `Shopify.Session` record and a map of | ||
query params. For example, this value could be `&Shopify.Product.all/2` to iterate over | ||
products or `&Shopify.Order.all/2` to iterate over orders. | ||
* `params` - Map of query params to be passed to `func` when fetching the first page. By | ||
default, most Shopify endpoints for listing items will return 50 items per page. If you plan | ||
on iterating over a large number of items then it will be more efficient to request the | ||
maximum number of items per page by specifying `%{limit: 250}`. | ||
|
||
## Options | ||
|
||
* `:middleware` - An optional middleware function whose primary purpose is to allow an | ||
application to throttle requests to avoid going over Shopify's rate limit. Must be a | ||
function with an arity of 1 that will be passed another function. The middleware must call | ||
that function and return the value returned by that function, which will be `{:ok, | ||
%Shopify.Response{}}` or `{:error, %Shopify.Error{}}`. Defaults to `& &1.()`. | ||
|
||
## Example | ||
|
||
Shopify.session() |> Shopify.Pagination.enumerable(&Shopify.Product.all/2) | ||
|> Stream.each(fn product -> IO.puts(product.id) end) | ||
|> Stream.run() | ||
|
||
""" | ||
def enumerable(session, func, params \\ %{}, opts \\ []) | ||
|
||
def enumerable(session, func, params, opts) when is_list(params) do | ||
enumerable(session, func, Enum.into(params, %{}), opts) | ||
end | ||
|
||
def enumerable(%Shopify.Session{} = session, func, %{} = params, opts) | ||
when is_function(func) and is_list(opts) do | ||
%Shopify.Enumerable{ | ||
data: [], | ||
func: func, | ||
middleware: Keyword.get(opts, :middleware, & &1.()), | ||
params: params, | ||
session: session | ||
} | ||
end | ||
|
||
@doc """ | ||
Returns a map containing the query params needed to fetch the next page of results when passed a | ||
`Shopify.Response` struct. It will return `nil` if there are no additional pages. This map will | ||
contain `"limit"` and `"page_info"` keys as [documented by | ||
Shopify](https://help.shopify.com/en/api/guides/paginated-rest-results). | ||
|
||
If any sorting or filtering params were passed with the API call for the first page then these | ||
params do not need to be passed when fetching subsequent pages. This information is encoded into | ||
the `page_info` value. | ||
|
||
## Example | ||
|
||
{:ok, resp} = Shopify.session() |> Shopify.Product.all() | ||
case Shopify.Pagination.next_page_params(resp) do | ||
nil -> | ||
# no additional pages | ||
nil | ||
page_params -> | ||
# fetch the next page | ||
{:ok, resp} = Shopify.session() |> Shopify.Product.all(page_params) | ||
# and so on... | ||
end | ||
""" | ||
def next_page_params(resp), do: page_params(resp, "next") | ||
|
||
defp page_params(%Response{headers: headers}, rel) do | ||
Enum.find_value(headers, fn {k, v} -> | ||
with "link" <- String.downcase(k), | ||
url when is_binary(url) <- parse_link_header(v)[rel], | ||
%URI{query: query} = URI.parse(url) do | ||
URI.decode_query(query) | ||
else | ||
_ -> nil | ||
end | ||
end) | ||
end | ||
|
||
# Returns map like %{"next" => <url>, "previous" => <url>}. Logic inspired by | ||
# https://github.com/Shopify/shopify_api/blob/master/lib/shopify_api/pagination_link_headers.rb | ||
defp parse_link_header(header) do | ||
header | ||
|> String.split(",") | ||
|> Enum.map(fn link -> | ||
[url_part, rel_part] = String.split(link, "; ") | ||
[rel] = Regex.run(~R{rel="(.*)"}, rel_part, capture: :all_but_first) | ||
[url] = Regex.run(~R{<(.*)>}, url_part, capture: :all_but_first) | ||
|
||
{rel, url} | ||
end) | ||
|> Enum.into(%{}) | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
defmodule Shopify.PaginationTest do | ||
use ExUnit.Case, async: true | ||
|
||
alias Shopify.{Error, Pagination, Response, Session} | ||
|
||
@next_link "<https://x.myshopify.com/admin/api/2019-10/products.json?limit=10&page_info=eyJsYXN0X2lkIjoxNTQwOTYwNTUwOTgyLCJsYXN0X3ZhbHVlIjoiRW5lcmd5IiwiZGlyZWN0aW9uIjoibmV4dCJ9>; rel=\"next\"" | ||
@next_prev_link "<https://x.myshopify.com/admin/api/2019-10/products.json?limit=10&page_info=eyJkaXJlY3Rpb24iOiJwcmV2IiwibGFzdF9pZCI6MTkzOTI2Nzc4MDY3OCwibGFzdF92YWx1ZSI6IkVuZXJneSBTYW1wbGVyIn0>; rel=\"previous\", <https://x.myshopify.com/admin/api/2019-10/products.json?limit=10&page_info=eyJkaXJlY3Rpb24iOiJuZXh0IiwibGFzdF9pZCI6MTc1MTIwMjM5ODI3OCwibGFzdF92YWx1ZSI6IkhhdCAtIFRoZXJlIGlzIG5vIG1hZ2ljIHBpbGwifQ>; rel=\"next\"" | ||
|
||
defp next_link(limit, page_info) do | ||
{"Link", "<https://x.com/path?limit=#{limit}&page_info=#{page_info}>; rel=\"next\""} | ||
end | ||
|
||
defp mock_list(%Session{}, p) when p == %{} do | ||
{:ok, %Response{data: ["a1", "a2"], headers: [next_link("10", "p_a2")]}} | ||
end | ||
|
||
defp mock_list(%Session{}, p) when p == %{"limit" => "10", "page_info" => "p_a2"} do | ||
{:ok, %Response{data: ["a3", "a4"], headers: []}} | ||
end | ||
|
||
defp mock_list(%Session{}, p) when p == %{broken: "end"} do | ||
{:ok, %Response{data: ["b1", "b2", "b3"], headers: [next_link("10", "p_b2")]}} | ||
end | ||
|
||
defp mock_list(%Session{}, p) when p == %{"limit" => "10", "page_info" => "p_b2"} do | ||
{:error, %Error{reason: :timeout, source: :httpoison}} | ||
end | ||
|
||
defp mock_list(%Session{}, p) when p == %{key: "foo"} do | ||
{:ok, %Response{data: ["f1", "f2", "f3"], headers: [next_link("20", "p_f2")]}} | ||
end | ||
|
||
defp mock_list(%Session{}, p) when p == %{"limit" => "20", "page_info" => "p_f2"} do | ||
{:ok, %Response{data: ["f4"], headers: []}} | ||
end | ||
|
||
test "enumerable with Enum.into/2" do | ||
assert ["a1", "a2", "a3", "a4"] == | ||
Shopify.session() | ||
|> Pagination.enumerable(&mock_list/2) | ||
|> Enum.into([]) | ||
|
||
assert ["f1", "f2", "f3", "f4"] == | ||
Shopify.session() | ||
|> Pagination.enumerable(&mock_list/2, key: "foo") | ||
|> Enum.into([]) | ||
|
||
exception = | ||
assert_raise(Error, fn -> | ||
Shopify.session() |> Pagination.enumerable(&mock_list/2, broken: "end") |> Enum.into([]) | ||
end) | ||
|
||
assert exception == %Error{reason: :timeout, source: :httpoison} | ||
end | ||
|
||
test "enumerable with Enum.take/2 (coverage for :halt)" do | ||
assert ["b1", "b2"] == | ||
Shopify.session() | ||
|> Pagination.enumerable(&mock_list/2, broken: "end") | ||
|> Enum.take(2) | ||
end | ||
|
||
test "enumerable with Enum.zip/2 (coverage for :suspend)" do | ||
assert [{"a1", "f1"}, {"a2", "f2"}, {"a3", "f3"}, {"a4", "f4"}] == | ||
Enum.zip( | ||
Shopify.session() |> Pagination.enumerable(&mock_list/2), | ||
Shopify.session() |> Pagination.enumerable(&mock_list/2, key: "foo") | ||
) | ||
end | ||
|
||
test "next_page_params" do | ||
assert nil == Pagination.next_page_params(%Response{headers: []}) | ||
|
||
assert %{ | ||
"limit" => "10", | ||
"page_info" => | ||
"eyJsYXN0X2lkIjoxNTQwOTYwNTUwOTgyLCJsYXN0X3ZhbHVlIjoiRW5lcmd5IiwiZGlyZWN0aW9uIjoibmV4dCJ9" | ||
} == | ||
%Response{headers: [{"Link", @next_link}]} | ||
|> Pagination.next_page_params() | ||
|
||
assert %{ | ||
"limit" => "10", | ||
"page_info" => | ||
"eyJkaXJlY3Rpb24iOiJuZXh0IiwibGFzdF9pZCI6MTc1MTIwMjM5ODI3OCwibGFzdF92YWx1ZSI6IkhhdCAtIFRoZXJlIGlzIG5vIG1hZ2ljIHBpbGwifQ" | ||
} == | ||
%Response{headers: [{"Link", @next_prev_link}]} | ||
|> Pagination.next_page_params() | ||
|
||
assert_raise MatchError, fn -> | ||
Pagination.next_page_params(%Response{headers: [{"Link", "nonsense"}]}) | ||
end | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason to
@enforce_keys
if the enforced keys are the struct keys?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@enforce_keys
makes it so that it is an error to instantiate the struct without all of the fields. It has the potential to prevent bugs where a field is accidentally omitted.It's optional and I can remove it if you'd like.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, I like the idea.