Class: Dinie::Page

Inherits:
Object
  • Object
show all
Defined in:
lib/dinie/runtime/paginator.rb

Overview

Note:

Items must respond to ‘#id` — the next page’s ‘starting_after` cursor is the `id` of the last item on the current page. Every list item in the frozen surface satisfies this.

‘Page` — one page of a cursor-paginated list, and the auto-pagination engine over the pages that follow it (architecture §11, RB8). Ports `sdk-js` `src/runtime/paginator.ts` and mirrors the mechanics of OpenAI Ruby’s ‘base_page.rb`. Part of the **public surface** (`Dinie::Page`).

Dinie has exactly ONE cursor scheme (inherited contract): ‘starting_after` (the `id` of the last item of the previous page) + `has_more` (the ONLY end-of-list signal — never `data.length == limit`, since the server may return fewer items with `has_more: true`).

A resource’s ‘list*` does the first request eagerly and returns a `Page` carrying the first page’s ‘data`, its `has_more`, and a `fetch_page` closure that knows how to fetch the next page (it maps a cursor to the `starting_after` query param and calls the transport). The `Page` owns when to call the closure and what cursor to pass; the resource owns how the closure reaches the network — so this class stays pure and is tested with an in-memory fake (no `HttpClient` coupling).

── Why NOT ‘include Enumerable` (RB8) ──`Enumerable` would graft on methods that materialize EVERY page eagerly (`to_a`, `count`, `sort`, `min`…). A paginator must stay lazy, so we deliberately define only `#each` (+ its `#auto_paging_each` alias) and `#each_page`; calling either without a block returns an `Enumerator`, which gives `.map` / `.first` / `.lazy` on demand without eager-loading.

── Iteration modes ──

* `#each` / `#auto_paging_each` — yield EVERY item of EVERY following page (auto-paginate).
* `#each_page` — yield page-by-page (manual control via `#next_page?` / `#next_page`).
* `#first(n)` — the first `n` items across pages, fetched lazily (stops as soon as it has `n`).

Examples:

auto-paginate every item

client.customers.list(limit: 50).each { |c| puts c.id }

page-by-page

client.customers.list.each_page { |page| process(page.data) }

just the first 10, lazily

top = client.customers.list.first(10)

Constant Summary collapse

NO_NEXT_PAGE_MESSAGE =

Message raised by #next_page when there is no next page to fetch.

"No next page; guard with `#next_page?` (or check `#has_more`) first."

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(data:, has_more:, fetch_page:) ⇒ Page

Returns a new instance of Page.

Parameters:

  • data (Array<Object>)

    this page’s items

  • has_more (Boolean)

    whether more pages follow

  • fetch_page (#call)

    ‘->(cursor) { envelope }` for the next page



85
86
87
88
89
90
# File 'lib/dinie/runtime/paginator.rb', line 85

def initialize(data:, has_more:, fetch_page:)
  @data = data
  @has_more = has_more
  @fetch_page = fetch_page
  freeze
end

Instance Attribute Details

#dataArray<Object> (readonly)

Items on this page — already deserialized into typed POROs by the resource’s ‘fetch_page`.

Returns:

  • (Array<Object>)


42
43
44
# File 'lib/dinie/runtime/paginator.rb', line 42

def data
  @data
end

#has_moreBoolean (readonly)

Whether the API reports more pages after this one — the ONLY end-of-list signal.

Returns:

  • (Boolean)


45
46
47
# File 'lib/dinie/runtime/paginator.rb', line 45

def has_more
  @has_more
end

Class Method Details

.build(envelope, fetch_page) ⇒ Dinie::Page

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Wrap a fetched ‘{ data:, has_more: }` envelope (already item-deserialized) in a `Page`.

Parameters:

  • envelope (Hash)

    ‘{ data: Array, has_more: Boolean }`

  • fetch_page (#call)

Returns:



67
68
69
# File 'lib/dinie/runtime/paginator.rb', line 67

def build(envelope, fetch_page)
  new(data: envelope[:data] || [], has_more: envelope[:has_more] == true, fetch_page: fetch_page)
end

.from_fetch(fetch_page) ⇒ Dinie::Page

Build the FIRST page by invoking ‘fetch_page` with no cursor (so the closure uses the caller’s ‘starting_after`, if any). This is the entry point a resource’s ‘list*` calls.

Parameters:

  • fetch_page (#call)

    ‘->(cursor) { { data: […], has_more: bool } }` — fetches the page after `cursor` (`nil` ⇒ the first page) and returns the wire envelope

Returns:



57
58
59
# File 'lib/dinie/runtime/paginator.rb', line 57

def from_fetch(fetch_page)
  build(fetch_page.call(nil), fetch_page)
end

.paginated?(envelope) ⇒ Boolean

The deterministic discriminator (architecture §7.5/§11): a list endpoint is paginated iff its envelope carries ‘has_more`. `/banks` lacks it, so the platform resource (story 010) returns a flat `Array<Bank>` instead of a `Page`. Exposed here so that rule has one home.

Parameters:

  • envelope (Object)

    a parsed response body

Returns:

  • (Boolean)

    true when the body is a paginated list envelope



77
78
79
# File 'lib/dinie/runtime/paginator.rb', line 77

def paginated?(envelope)
  envelope.is_a?(Hash) && envelope.key?(:has_more)
end

Instance Method Details

#each {|item| ... } ⇒ self, Enumerator Also known as: auto_paging_each

Auto-paginate: yield every item of this page, then every item of each following page, until ‘has_more` is false. Without a block, returns an `Enumerator` (`.map` / `.first` / `.lazy`).

Yield Parameters:

  • item (Object)

Returns:

  • (self, Enumerator)


117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/dinie/runtime/paginator.rb', line 117

def each(&block)
  return enum_for(:each) unless block_given?

  page = self
  loop do
    page.data.each(&block)
    break unless page.next_page?

    page = page.next_page
  end
  self
end

#each_page {|page| ... } ⇒ self, Enumerator

Yield page-by-page (manual control), following the cursor until ‘has_more` is false. Without a block, returns an `Enumerator` over the pages.

Yield Parameters:

Returns:

  • (self, Enumerator)


136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/dinie/runtime/paginator.rb', line 136

def each_page
  return enum_for(:each_page) unless block_given?

  page = self
  loop do
    yield page
    break unless page.next_page?

    page = page.next_page
  end
  self
end

#first(count = UNSPECIFIED) ⇒ Object+

The first ‘count` items across pages (or the very first item when `count` is omitted), fetched lazily — only as many pages as needed are requested.

Parameters:

  • count (Integer, nil) (defaults to: UNSPECIFIED)

    how many items to take; omit for a single item

Returns:

  • (Object, Array<Object>)


154
155
156
157
158
# File 'lib/dinie/runtime/paginator.rb', line 154

def first(count = UNSPECIFIED)
  return auto_paging_each.first if count.equal?(UNSPECIFIED)

  auto_paging_each.first(count)
end

#next_pageDinie::Page

Fetch the next page. The cursor is the ‘id` of the LAST item on this page (mapped to `starting_after` by the closure). Guard with #next_page? before calling.

Returns:

Raises:



106
107
108
109
110
# File 'lib/dinie/runtime/paginator.rb', line 106

def next_page
  raise Dinie::Error, NO_NEXT_PAGE_MESSAGE unless next_page?

  self.class.build(@fetch_page.call(@data.last.id), @fetch_page)
end

#next_page?Boolean

Whether a next page can be fetched. Driven by ‘has_more`; the `!data.empty?` guard is a safety net — an empty page has no last item (so no cursor), so we stop rather than loop forever on a malformed `{ data: [], has_more: true }`.

Returns:

  • (Boolean)


97
98
99
# File 'lib/dinie/runtime/paginator.rb', line 97

def next_page?
  @has_more && !@data.empty?
end