Skip to content

Add seam.create_paginator #272

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

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ accurate and fully typed.
- [API Key](#api-key)
- [Personal Access Token](#personal-access-token)
- [Action Attempts](#action-attempts)
- [Pagination](#pagination)
- [Manually fetch pages with the next_page_cursor](#manually-fetch-pages-with-the-next_page_cursor)
- [Resume pagination](#resume-pagination)
- [Iterate over all resources](#iterate-over-all-resources)
- [Return all resources across all pages as a list](#return-all-resources-across-all-pages-as-a-list)
- [Interacting with Multiple Workspaces](#interacting-with-multiple-workspaces)
- [Webhooks](#webhooks)
- [Advanced Usage](#advanced-usage)
Expand Down Expand Up @@ -215,6 +220,99 @@ rescue Seam::ActionAttemptTimeoutError
end
```

### Pagination

Some Seam API endpoints that return lists of resources support pagination.
Use the `Seam::Paginator` class to fetch and process resources across multiple pages.

#### Manually fetch pages with the next_page_cursor

```ruby
require "seam"

seam = Seam.new

paginator = seam.create_paginator(seam.connected_accounts.method(:list), {limit: 20})

connected_accounts, pagination = paginator.first_page

if pagination.has_next_page?
more_connected_accounts, _ = paginator.next_page(pagination.next_page_cursor)
end
```

#### Resume pagination

Get the first page on initial load and store the state (e.g., in memory or a file):

```ruby
require "seam"
require "json"

seam = Seam.new

params = {limit: 20}
paginator = seam.create_paginator(seam.connected_accounts.method(:list), params)

connected_accounts, pagination = paginator.first_page

# Example: Store state for later use (e.g., in a file or database)
pagination_state = {
"params" => params,
"next_page_cursor" => pagination.next_page_cursor,
"has_next_page" => pagination.has_next_page?
}
File.write("/tmp/seam_connected_accounts_list.json", JSON.dump(pagination_state))
```

Get the next page at a later time using the stored state:

```ruby
require "seam"
require "json"

seam = Seam.new

# Example: Load state from where it was stored
pagination_state_json = File.read("/tmp/seam_connected_accounts_list.json")
pagination_state = JSON.parse(pagination_state_json)

if pagination_state["has_next_page"]
paginator = seam.create_paginator(
seam.connected_accounts.method(:list), pagination_state["params"]
)
more_connected_accounts, _ = paginator.next_page(
pagination_state["next_page_cursor"]
)
end
```

#### Iterate over all resources

```ruby
require "seam"

seam = Seam.new

paginator = seam.create_paginator(seam.connected_accounts.method(:list), {limit: 20})

paginator.flatten.each do |account|
puts account.account_type_display_name
end
```

#### Return all resources across all pages as a list

```ruby
require "seam"

seam = Seam.new

paginator = seam.create_paginator(seam.connected_accounts.method(:list), {limit: 20})

all_connected_accounts = paginator.flatten_to_list
```

### Interacting with Multiple Workspaces

Some Seam API endpoints interact with multiple workspaces. The `Seam::Http::SeamMultiWorkspace` client is not bound to a specific workspace and may use those endpoints with a personal access token authentication method.
Expand Down
5 changes: 5 additions & 0 deletions lib/seam/http_single_workspace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
require_relative "routes/routes"
require_relative "version"
require_relative "deep_hash_accessor"
require_relative "paginator"

module Seam
module Http
Expand All @@ -32,6 +33,10 @@ def lts_version
Seam::LTS_VERSION
end

def create_paginator(request, params = {})
Paginator.new(request, params)
end

def self.from_api_key(api_key, endpoint: nil, wait_for_action_attempt: false, faraday_options: {}, faraday_retry_options: {})
new(api_key: api_key, endpoint: endpoint, wait_for_action_attempt: wait_for_action_attempt,
faraday_options: faraday_options, faraday_retry_options: faraday_retry_options)
Expand Down
106 changes: 106 additions & 0 deletions lib/seam/paginator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# frozen_string_literal: true

require "faraday"
require_relative "http"

module Seam
THREAD_CONTEXT_KEY = :seam_pagination_context
PaginationContext = Struct.new(:pagination)

class Paginator
def initialize(request, params = {})
raise ArgumentError, "request must be a Method" unless request.is_a?(Method)
raise ArgumentError, "params must be a Hash" unless params.is_a?(Hash)

@request = request
@params = params.transform_keys(&:to_sym)
end

def first_page
fetch_page(@params)
end

def next_page(next_page_cursor)
if next_page_cursor.nil? || next_page_cursor.empty?
raise ArgumentError,
"Cannot get the next page with a nil or empty next_page_cursor."
end

fetch_page(@params.merge(page_cursor: next_page_cursor))
end

def flatten_to_list
all_items = []
current_items, pagination = first_page

all_items.concat(current_items) if current_items

while pagination&.has_next_page? && (cursor = pagination.next_page_cursor)
current_items, pagination = next_page(cursor)
all_items.concat(current_items) if current_items
end

all_items
end

def flatten
Enumerator.new do |yielder|
current_items, pagination = first_page
current_items&.each { |item| yielder << item }

while pagination&.has_next_page? && (cursor = pagination.next_page_cursor)
current_items, pagination = next_page(cursor)
current_items&.each { |item| yielder << item }
end
end
end

private

def fetch_page(params)
context = PaginationContext.new(nil)
Thread.current[THREAD_CONTEXT_KEY] = context

begin
res_data = @request.call(**params)
pagination_result = Pagination.from_hash(context.pagination)
[res_data, pagination_result]
ensure
Thread.current[THREAD_CONTEXT_KEY] = nil
end
end
end

Pagination = Struct.new(:has_next_page, :next_page_cursor, :next_page_url, keyword_init: true) do
def self.from_hash(hash)
return nil unless hash.is_a?(Hash) && !hash.empty?

new(
has_next_page: hash.fetch("has_next_page", false),
next_page_cursor: hash.fetch("next_page_cursor", nil),
next_page_url: hash.fetch("next_page_url", nil)
)
end

def has_next_page?
has_next_page == true
end
end

class PaginationMiddleware < Faraday::Middleware
def on_complete(env)
context = Thread.current[THREAD_CONTEXT_KEY]
return unless context.is_a?(PaginationContext)

pagination_hash = extract_pagination(env)
context.pagination = pagination_hash if pagination_hash
end

private

def extract_pagination(env)
body = env[:body]
body["pagination"] if body.is_a?(Hash) && body.key?("pagination")
end
end
end
2 changes: 2 additions & 0 deletions lib/seam/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require "faraday/retry"
require_relative "lts_version"
require_relative "version"
require_relative "paginator"

module Seam
module Http
Expand All @@ -25,6 +26,7 @@ def self.create_faraday_client(endpoint, auth_headers, faraday_options = {}, far

Faraday.new(options) do |builder|
builder.request :json
builder.use Seam::PaginationMiddleware
builder.response :json
builder.use ResponseMiddleware
builder.request :retry, faraday_retry_options
Expand Down
86 changes: 86 additions & 0 deletions spec/paginator_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# frozen_string_literal: true

require "spec_helper"
require "seam/paginator"

RSpec.describe Seam::Paginator do
around do |example|
with_fake_seam_connect do |seam, _endpoint, _seed|
@seam = seam
example.run
end
end

let(:seam) { @seam }

describe "#first_page" do
it "fetches the first page of results and pagination info" do
paginator = seam.create_paginator(seam.connected_accounts.method(:list), {limit: 2})
connected_accounts, pagination = paginator.first_page

expect(connected_accounts).to be_a(Array)
expect(connected_accounts.size).to eq(2)
expect(connected_accounts.first).to be_a(Seam::Resources::ConnectedAccount)

expect(pagination).to be_a(Seam::Pagination)
expect(pagination.has_next_page?).to be true
expect(pagination.next_page_cursor).to be_a(String)
expect(pagination.next_page_url).to match(%r{/connected_accounts/list.*[?&]next_page_cursor=})
end
end

describe "#next_page" do
it "fetches the next page of results" do
paginator = seam.create_paginator(seam.connected_accounts.method(:list), {limit: 2})
_first_page_accounts, first_pagination = paginator.first_page

expect(first_pagination.has_next_page?).to be true

next_page_accounts, next_pagination = paginator.next_page(first_pagination.next_page_cursor)

expect(next_page_accounts).to be_a(Array)
expect(next_page_accounts.size).to eq(1) # 3 total accounts, limit 2 -> page 1 has 2, page 2 has 1
expect(next_page_accounts.first).to be_a(Seam::Resources::ConnectedAccount)

expect(next_pagination).to be_a(Seam::Pagination)
expect(next_pagination.has_next_page?).to be false
expect(next_pagination.next_page_cursor).to be_nil
end

it "raises ArgumentError if next_page_cursor is nil or empty" do
paginator = seam.create_paginator(seam.connected_accounts.method(:list), {limit: 2})
expect { paginator.next_page(nil) }.to raise_error(ArgumentError, /nil or empty next_page_cursor/)
expect { paginator.next_page("") }.to raise_error(ArgumentError, /nil or empty next_page_cursor/)
end
end

describe "#flatten_to_list" do
it "fetches all items from all pages into a single list" do
total_accounts = seam.connected_accounts.list.size
paginator = seam.create_paginator(seam.connected_accounts.method(:list), {limit: 1})
paginated_accounts = paginator.flatten_to_list

expect(paginated_accounts).to be_a(Array)
expect(paginated_accounts.size).to be > 1
expect(paginated_accounts.size).to eq(total_accounts)
expect(paginated_accounts.first).to be_a(Seam::Resources::ConnectedAccount)
end
end

describe "#flatten" do
it "returns an Enumerator that yields all items from all pages" do
total_accounts = seam.connected_accounts.list.size
paginator = seam.create_paginator(seam.connected_accounts.method(:list), {limit: 1})

collected_accounts = []
paginator.flatten.each do |account|
collected_accounts << account
end

expect(collected_accounts).to be_a(Array)
expect(collected_accounts.size).to be > 1
expect(collected_accounts.size).to eq(total_accounts)
expect(collected_accounts.first).to be_a(Seam::Resources::ConnectedAccount)
end
end
end