printavo-ruby 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8c80d0cf1950d67050c32a5d20e9bc6d001587fb509c3f52831b6dfbec77e5b3
4
- data.tar.gz: 2d117bae60f460bd9ba9aaa2571bdeacb4915e361224e3f3d61183e2b52a33df
3
+ metadata.gz: 4bdc16c358a3dafbdae1842e0ddb8eebbe8775053b1b95efb2308cd33056e2ea
4
+ data.tar.gz: 7399f57922f9244a02900c458bf9d2b9830aa0d63a5b4ff07952ca3ade6254fd
5
5
  SHA512:
6
- metadata.gz: 1858601061541936154d9d8daad2b30950dd737501502681e266966f8de415eb84f15c0fa9ba3d8ac2725a27990d8cb000728a9be03c0d5c7e86c13362d53b06
7
- data.tar.gz: 3ff3869148ccbabb980d352b1a3270de1c63d18eaf6d512a96fb116cd9c346718f90b7121a13455d582b7e205a1736ee357437f8f1a34cd9e83b572cd7d63184
6
+ metadata.gz: fa26e26063d2ed94011d20f22c4ae6c9fa964c62d2fa07b31343b97f2d1e9dbe623114960dab489cca029fe0d1f036964c7748b27a96e1c2eb7d63d33d3d285f
7
+ data.tar.gz: 0427ed5bc1601858080cb1f057547cb4143cc51d32cbd7189fa4fc8546260045c11603204814e28656b26a97f00e286634b24f8b536c879d14e5f8dd44f8313c
data/README.md CHANGED
@@ -116,6 +116,26 @@ job = client.jobs.find("77")
116
116
  puts job.taxable? # => true
117
117
  ```
118
118
 
119
+ ### Pagination
120
+
121
+ All list resources support `each_page` and `all_pages` in addition to `all`.
122
+
123
+ ```ruby
124
+ # Iterate page by page (cursor-based, memory-efficient)
125
+ client.customers.each_page(first: 50) do |records|
126
+ records.each { |c| puts c.full_name }
127
+ end
128
+
129
+ # Collect every record across all pages into one array
130
+ all_orders = client.orders.all_pages
131
+
132
+ # Jobs require order_id
133
+ client.jobs.each_page(order_id: "99") do |records|
134
+ records.each { |j| puts j.name }
135
+ end
136
+ all_jobs = client.jobs.all_pages(order_id: "99")
137
+ ```
138
+
119
139
  ### Statuses
120
140
 
121
141
  ```ruby
@@ -176,6 +196,42 @@ result = client.graphql.query(
176
196
  )
177
197
  ```
178
198
 
199
+ ### Mutations
200
+
201
+ Use `mutate` for GraphQL write operations:
202
+
203
+ ```ruby
204
+ result = client.graphql.mutate(
205
+ <<~GQL,
206
+ mutation UpdateOrder($id: ID!, $nickname: String!) {
207
+ updateOrder(id: $id, input: { nickname: $nickname }) {
208
+ order { id nickname }
209
+ errors
210
+ }
211
+ }
212
+ GQL
213
+ variables: { id: "99", nickname: "Rush Job" }
214
+ )
215
+ result["updateOrder"]["order"]["nickname"] # => "Rush Job"
216
+ ```
217
+
218
+ ### Raw GraphQL Pagination
219
+
220
+ Paginate any custom query without a resource wrapper using `paginate`.
221
+ The `path:` option is dot-separated and maps to the connection in the response:
222
+
223
+ ```ruby
224
+ client.graphql.paginate(MY_QUERY, path: "orders", first: 50) do |nodes|
225
+ nodes.each { |n| puts n["nickname"] }
226
+ end
227
+
228
+ # Nested connection (e.g. order.lineItems)
229
+ client.graphql.paginate(JOBS_QUERY, path: "order.lineItems",
230
+ variables: { orderId: "99" }) do |nodes|
231
+ nodes.each { |j| puts j["name"] }
232
+ end
233
+ ```
234
+
179
235
  ## Webhooks
180
236
 
181
237
  `Printavo::Webhooks.verify` provides Rack-compatible HMAC-SHA256 signature verification.
@@ -234,15 +290,14 @@ end
234
290
 
235
291
  | Version | Milestone |
236
292
  |---|---|
237
- | 0.1.0 | Auth + Customers + Orders + Jobs ✅ |
293
+ | 0.1.0 | Auth + Customers + Orders + Jobs + Webhooks (Rack-compatible) ✅ |
238
294
  | 0.2.0 | Status registry + Inquiries ✅ |
239
- | 0.3.0 | Webhooks (Rack-compatible) |
240
- | 0.4.0 | Expanded GraphQL DSL |
295
+ | 0.3.0 | Pagination helpers (`each_page`, `all_pages`) + `bin/lint` ✅ |
296
+ | 0.4.0 | Expanded GraphQL DSL (`mutate`, `paginate`) ✅ |
241
297
  | 0.5.0 | Mutations (create/update) |
242
298
  | 0.6.0 | Analytics / Reporting queries |
243
299
  | 0.7.0 | Community burn-in / API stabilization |
244
- | 0.8.0 | Pagination abstraction helpers |
245
- | 0.9.0 | Retry/backoff + rate limit awareness |
300
+ | 0.8.0 | Retry/backoff + rate limit awareness |
246
301
  | 1.0.0 | Stable public SDK |
247
302
 
248
303
  **Rules**: `PATCH` = bug fix · `MINOR` = new backward-compatible feature · `MAJOR` = breaking change
data/docs/CHANGELOG.md CHANGED
@@ -8,6 +8,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.4.0] - 2026-03-30
12
+
13
+ ### Added
14
+ - `GraphqlClient#mutate` — semantic method for GraphQL mutations; identical transport to `query` but signals write intent at the call site; lays the foundation for 0.5.0 resource-level mutations
15
+ - `GraphqlClient#paginate` — cursor-following pagination for raw GraphQL queries; accepts a dot-separated `path:` to resolve nested connections (e.g. `"order.lineItems"`); yields each page's `nodes` array
16
+ - `GraphqlClient#execute` (private) — extracted shared POST logic; `query` and `mutate` both delegate to it
17
+ - `GraphqlClient#dig_path` (private) — resolves dot-separated key paths against nested response hashes
18
+ - Roadmap: removed stale Pagination entry from 0.8.0 (shipped in 0.3.0); Retry/backoff renumbered to 0.8.0
19
+
20
+ ## [0.3.0] - 2026-03-29
21
+
22
+ ### Added
23
+ - `Printavo::Page` — value object wrapping `records`, `has_next_page`, `end_cursor` with `to_a`, `size`, `empty?`
24
+ - `Printavo::Resources::Base#each_page` — yields each page of records as an Array, following cursors automatically
25
+ - `Printavo::Resources::Base#all_pages` — returns all records across all pages as a flat Array
26
+ - All resources (`Customers`, `Orders`, `Jobs`, `Statuses`, `Inquiries`) implement `fetch_page` and support `each_page`/`all_pages`
27
+ - `Jobs#each_page(order_id:)` and `Jobs#all_pages(order_id:)` — `order_id` forwarded via `**kwargs`
28
+ - `bin/lint` — multi-Ruby RuboCop runner mirroring `bin/spec` (reads versions from `.mise.toml`)
29
+ - Roadmap: moved Pagination from 0.8.0 to 0.3.0; Webhooks slot repurposed
30
+
31
+ ### Changed
32
+ - All resource `all` methods refactored to delegate to `fetch_page` (backward compatible — still returns `Array`)
33
+
11
34
  ## [0.2.0] - 2026-03-29
12
35
 
13
36
  ### Added
data/docs/FUTURE.md CHANGED
@@ -20,21 +20,6 @@ printavo sync orders --to crm
20
20
 
21
21
  Planned version: `0.7.0`
22
22
 
23
- ### Pagination Abstraction
24
-
25
- A lazy-enumerator-style helper to automatically page through all results:
26
-
27
- ```ruby
28
- client.customers.each_page do |page|
29
- page.each { |c| process(c) }
30
- end
31
-
32
- # Or collect all:
33
- all_orders = client.orders.all_pages
34
- ```
35
-
36
- Planned version: `0.8.0`
37
-
38
23
  ### Retry/Backoff
39
24
 
40
25
  Intelligent rate limit handling with exponential backoff:
@@ -48,7 +33,7 @@ client = Printavo::Client.new(
48
33
  )
49
34
  ```
50
35
 
51
- Planned version: `0.9.0`
36
+ Planned version: `0.8.0`
52
37
 
53
38
  ### Analytics / Reporting Expansion
54
39
 
@@ -7,16 +7,90 @@ module Printavo
7
7
  @connection = connection
8
8
  end
9
9
 
10
+ # Executes a GraphQL query and returns the parsed `data` hash.
11
+ #
12
+ # @param query_string [String] the GraphQL query document
13
+ # @param variables [Hash] optional input variables
14
+ # @return [Hash]
15
+ #
16
+ # @example
17
+ # client.graphql.query("{ customers { nodes { id } } }")
18
+ # client.graphql.query(
19
+ # "query Customer($id: ID!) { customer(id: $id) { id email } }",
20
+ # variables: { id: "42" }
21
+ # )
10
22
  def query(query_string, variables: {})
11
- response = @connection.post('') do |req|
12
- req.body = JSON.generate(query: query_string, variables: variables)
13
- end
23
+ execute(query_string, variables: variables)
24
+ end
14
25
 
15
- handle_response(response)
26
+ # Executes a GraphQL mutation and returns the parsed `data` hash.
27
+ # Semantically equivalent to `query` — both POST to the same endpoint —
28
+ # but distinguishes write intent at the call site.
29
+ #
30
+ # @param mutation_string [String] the GraphQL mutation document
31
+ # @param variables [Hash] optional input variables
32
+ # @return [Hash]
33
+ #
34
+ # @example
35
+ # client.graphql.mutate(
36
+ # <<~GQL,
37
+ # mutation UpdateOrder($id: ID!, $input: OrderInput!) {
38
+ # updateOrder(id: $id, input: $input) {
39
+ # order { id nickname }
40
+ # errors
41
+ # }
42
+ # }
43
+ # GQL
44
+ # variables: { id: "99", input: { nickname: "Rush Job" } }
45
+ # )
46
+ def mutate(mutation_string, variables: {})
47
+ execute(mutation_string, variables: variables)
48
+ end
49
+
50
+ # Iterates all pages of a paginated GraphQL query, yielding each page's
51
+ # nodes array. The query must accept `$first: Int` and `$after: String`
52
+ # variables, and the target connection must expose `nodes` and `pageInfo`.
53
+ #
54
+ # @param query_string [String] the GraphQL query document
55
+ # @param path [String] dot-separated key path to the connection in the response
56
+ # e.g. "orders" or "customer.orders"
57
+ # @param variables [Hash] additional variables merged with `first` and `after`
58
+ # @param first [Integer] page size (default 25)
59
+ # @yieldparam nodes [Array<Hash>] one page of raw node hashes
60
+ #
61
+ # @example
62
+ # client.graphql.paginate(ORDERS_QUERY, path: "orders") do |nodes|
63
+ # nodes.each { |n| puts n["nickname"] }
64
+ # end
65
+ #
66
+ # @example With extra variables
67
+ # client.graphql.paginate(JOBS_QUERY, path: "order.lineItems",
68
+ # variables: { orderId: "99" }, first: 50) do |nodes|
69
+ # nodes.each { |j| puts j["name"] }
70
+ # end
71
+ def paginate(query_string, path:, variables: {}, first: 25)
72
+ after = nil
73
+ loop do
74
+ data = execute(query_string, variables: variables.merge(first: first, after: after))
75
+ conn = dig_path(data, path)
76
+ nodes = conn&.fetch('nodes', []) || []
77
+ yield nodes
78
+ page_info = conn&.fetch('pageInfo', {}) || {}
79
+ break unless page_info['hasNextPage']
80
+
81
+ after = page_info['endCursor']
82
+ end
16
83
  end
17
84
 
18
85
  private
19
86
 
87
+ def execute(document, variables: {})
88
+ response = @connection.post('') do |req|
89
+ req.body = JSON.generate(query: document, variables: variables)
90
+ end
91
+ handle_response(response)
92
+ end
93
+
20
94
  def handle_response(response)
21
95
  body = response.body
22
96
 
@@ -34,5 +108,11 @@ module Printavo
34
108
 
35
109
  body.is_a?(Hash) ? body['data'] : body
36
110
  end
111
+
112
+ # Resolves a dot-separated path against a nested hash.
113
+ # e.g. dig_path(data, "customer.orders") => data["customer"]["orders"]
114
+ def dig_path(data, path)
115
+ path.split('.').reduce(data) { |obj, key| obj.is_a?(Hash) ? obj[key] : nil }
116
+ end
37
117
  end
38
118
  end
@@ -0,0 +1,20 @@
1
+ # lib/printavo/page.rb
2
+ module Printavo
3
+ # Wraps a single page of API results with cursor metadata.
4
+ #
5
+ # @example Iterating pages manually
6
+ # page = client.customers.fetch_page(first: 10)
7
+ # page.records.each { |c| puts c.full_name }
8
+ # puts page.has_next_page # => true
9
+ # puts page.end_cursor # => "cursor_abc123"
10
+ #
11
+ # @example Using each_page
12
+ # client.customers.each_page(first: 10) do |records|
13
+ # records.each { |c| puts c.full_name }
14
+ # end
15
+ Page = Struct.new(:records, :has_next_page, :end_cursor, keyword_init: true) do
16
+ def to_a = records
17
+ def size = records.size
18
+ def empty? = records.empty?
19
+ end
20
+ end
@@ -5,6 +5,47 @@ module Printavo
5
5
  def initialize(graphql)
6
6
  @graphql = graphql
7
7
  end
8
+
9
+ # Yields each page of records as an Array, following cursors automatically.
10
+ # Extra keyword arguments are forwarded to fetch_page (e.g. order_id: for Jobs).
11
+ #
12
+ # @param first [Integer] page size (default 25)
13
+ # @yieldparam records [Array] one page of domain model objects
14
+ #
15
+ # @example
16
+ # client.customers.each_page(first: 50) do |records|
17
+ # records.each { |c| puts c.full_name }
18
+ # end
19
+ def each_page(first: 25, **kwargs)
20
+ after = nil
21
+ loop do
22
+ page = fetch_page(first: first, after: after, **kwargs)
23
+ yield(page.records)
24
+ break unless page.has_next_page
25
+
26
+ after = page.end_cursor
27
+ end
28
+ end
29
+
30
+ # Returns all records across all pages as a flat Array.
31
+ # Extra keyword arguments are forwarded to fetch_page (e.g. order_id: for Jobs).
32
+ #
33
+ # @param first [Integer] page size per request (default 25)
34
+ # @return [Array]
35
+ #
36
+ # @example
37
+ # all_customers = client.customers.all_pages
38
+ # all_jobs = client.jobs.all_pages(order_id: "99")
39
+ def all_pages(first: 25, **kwargs)
40
+ [].tap { |all| each_page(first: first, **kwargs) { |records| all.concat(records) } }
41
+ end
42
+
43
+ private
44
+
45
+ # Subclasses must implement: returns a Printavo::Page.
46
+ def fetch_page(**)
47
+ raise NotImplementedError, "#{self.class}#fetch_page is not implemented"
48
+ end
8
49
  end
9
50
  end
10
51
  end
@@ -35,14 +35,26 @@ module Printavo
35
35
  GQL
36
36
 
37
37
  def all(first: 25, after: nil)
38
- data = @graphql.query(ALL_QUERY, variables: { first: first, after: after })
39
- data['customers']['nodes'].map { |attrs| Printavo::Customer.new(attrs) }
38
+ fetch_page(first: first, after: after).records
40
39
  end
41
40
 
42
41
  def find(id)
43
42
  data = @graphql.query(FIND_QUERY, variables: { id: id.to_s })
44
43
  Printavo::Customer.new(data['customer'])
45
44
  end
45
+
46
+ private
47
+
48
+ def fetch_page(first: 25, after: nil, **)
49
+ data = @graphql.query(ALL_QUERY, variables: { first: first, after: after })
50
+ nodes = data['customers']['nodes'].map { |attrs| Printavo::Customer.new(attrs) }
51
+ page_info = data['customers']['pageInfo']
52
+ Printavo::Page.new(
53
+ records: nodes,
54
+ has_next_page: page_info['hasNextPage'],
55
+ end_cursor: page_info['endCursor']
56
+ )
57
+ end
46
58
  end
47
59
  end
48
60
  end
@@ -53,14 +53,26 @@ module Printavo
53
53
  GQL
54
54
 
55
55
  def all(first: 25, after: nil)
56
- data = @graphql.query(ALL_QUERY, variables: { first: first, after: after })
57
- data['inquiries']['nodes'].map { |attrs| Printavo::Inquiry.new(attrs) }
56
+ fetch_page(first: first, after: after).records
58
57
  end
59
58
 
60
59
  def find(id)
61
60
  data = @graphql.query(FIND_QUERY, variables: { id: id.to_s })
62
61
  Printavo::Inquiry.new(data['inquiry'])
63
62
  end
63
+
64
+ private
65
+
66
+ def fetch_page(first: 25, after: nil, **)
67
+ data = @graphql.query(ALL_QUERY, variables: { first: first, after: after })
68
+ nodes = data['inquiries']['nodes'].map { |attrs| Printavo::Inquiry.new(attrs) }
69
+ page_info = data['inquiries']['pageInfo']
70
+ Printavo::Page.new(
71
+ records: nodes,
72
+ has_next_page: page_info['hasNextPage'],
73
+ end_cursor: page_info['endCursor']
74
+ )
75
+ end
64
76
  end
65
77
  end
66
78
  end
@@ -35,14 +35,29 @@ module Printavo
35
35
  GQL
36
36
 
37
37
  def all(order_id:, first: 25, after: nil)
38
- data = @graphql.query(ALL_QUERY, variables: { orderId: order_id.to_s, first: first, after: after })
39
- data['order']['lineItems']['nodes'].map { |attrs| Printavo::Job.new(attrs) }
38
+ fetch_page(order_id: order_id, first: first, after: after).records
40
39
  end
41
40
 
42
41
  def find(id)
43
42
  data = @graphql.query(FIND_QUERY, variables: { id: id.to_s })
44
43
  Printavo::Job.new(data['lineItem'])
45
44
  end
45
+
46
+ private
47
+
48
+ def fetch_page(order_id:, first: 25, after: nil, **)
49
+ data = @graphql.query(
50
+ ALL_QUERY,
51
+ variables: { orderId: order_id.to_s, first: first, after: after }
52
+ )
53
+ nodes = data['order']['lineItems']['nodes'].map { |attrs| Printavo::Job.new(attrs) }
54
+ page_info = data['order']['lineItems']['pageInfo']
55
+ Printavo::Page.new(
56
+ records: nodes,
57
+ has_next_page: page_info['hasNextPage'],
58
+ end_cursor: page_info['endCursor']
59
+ )
60
+ end
46
61
  end
47
62
  end
48
63
  end
@@ -39,6 +39,7 @@ module Printavo
39
39
  status {
40
40
  id
41
41
  name
42
+ color
42
43
  }
43
44
  customer {
44
45
  id
@@ -52,14 +53,26 @@ module Printavo
52
53
  GQL
53
54
 
54
55
  def all(first: 25, after: nil)
55
- data = @graphql.query(ALL_QUERY, variables: { first: first, after: after })
56
- data['orders']['nodes'].map { |attrs| Printavo::Order.new(attrs) }
56
+ fetch_page(first: first, after: after).records
57
57
  end
58
58
 
59
59
  def find(id)
60
60
  data = @graphql.query(FIND_QUERY, variables: { id: id.to_s })
61
61
  Printavo::Order.new(data['order'])
62
62
  end
63
+
64
+ private
65
+
66
+ def fetch_page(first: 25, after: nil, **)
67
+ data = @graphql.query(ALL_QUERY, variables: { first: first, after: after })
68
+ nodes = data['orders']['nodes'].map { |attrs| Printavo::Order.new(attrs) }
69
+ page_info = data['orders']['pageInfo']
70
+ Printavo::Page.new(
71
+ records: nodes,
72
+ has_next_page: page_info['hasNextPage'],
73
+ end_cursor: page_info['endCursor']
74
+ )
75
+ end
63
76
  end
64
77
  end
65
78
  end
@@ -29,8 +29,7 @@ module Printavo
29
29
  GQL
30
30
 
31
31
  def all(first: 100, after: nil)
32
- data = @graphql.query(ALL_QUERY, variables: { first: first, after: after })
33
- data['statuses']['nodes'].map { |attrs| Printavo::Status.new(attrs) }
32
+ fetch_page(first: first, after: after).records
34
33
  end
35
34
 
36
35
  def find(id)
@@ -45,6 +44,19 @@ module Printavo
45
44
  def registry
46
45
  all.to_h { |status| [status.key, status] }
47
46
  end
47
+
48
+ private
49
+
50
+ def fetch_page(first: 100, after: nil, **)
51
+ data = @graphql.query(ALL_QUERY, variables: { first: first, after: after })
52
+ nodes = data['statuses']['nodes'].map { |attrs| Printavo::Status.new(attrs) }
53
+ page_info = data['statuses']['pageInfo']
54
+ Printavo::Page.new(
55
+ records: nodes,
56
+ has_next_page: page_info['hasNextPage'],
57
+ end_cursor: page_info['endCursor']
58
+ )
59
+ end
48
60
  end
49
61
  end
50
62
  end
@@ -1,4 +1,4 @@
1
1
  # lib/printavo/version.rb
2
2
  module Printavo
3
- VERSION = '0.2.0'.freeze
3
+ VERSION = '0.4.0'.freeze
4
4
  end
data/lib/printavo.rb CHANGED
@@ -8,6 +8,7 @@ require_relative 'printavo/errors'
8
8
  require_relative 'printavo/config'
9
9
  require_relative 'printavo/connection'
10
10
  require_relative 'printavo/graphql_client'
11
+ require_relative 'printavo/page'
11
12
  require_relative 'printavo/models/base'
12
13
  require_relative 'printavo/models/customer'
13
14
  require_relative 'printavo/models/status'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: printavo-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stan Carver II
@@ -277,6 +277,7 @@ files:
277
277
  - lib/printavo/models/job.rb
278
278
  - lib/printavo/models/order.rb
279
279
  - lib/printavo/models/status.rb
280
+ - lib/printavo/page.rb
280
281
  - lib/printavo/resources/base.rb
281
282
  - lib/printavo/resources/customers.rb
282
283
  - lib/printavo/resources/inquiries.rb