printavo-ruby 0.18.1 → 0.20.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 +4 -4
- data/README.md +55 -0
- data/docs/CHANGELOG.md +69 -0
- data/lib/printavo/client.rb +6 -1
- data/lib/printavo/connection.rb +1 -1
- data/lib/printavo/errors.rb +2 -0
- data/lib/printavo/graphql/contacts/all.graphql +36 -0
- data/lib/printavo/graphql_client.rb +153 -12
- data/lib/printavo/page.rb +24 -1
- data/lib/printavo/resources/contacts.rb +70 -0
- data/lib/printavo/response_envelope.rb +56 -0
- data/lib/printavo/version.rb +1 -1
- data/lib/printavo.rb +1 -0
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6ad593920d96da45a59747ebfaab7fce5d5a1b411e0f6afa781bca1fefcca158
|
|
4
|
+
data.tar.gz: 6eb415c85c7566986565daddcd6ebd5065d87af95680599ba0b4717892de20e0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d95dc0294fe872e6db56d48ab7309c668f24edc5caadf22a5f1333407cac2f31d6845fef845c0cd126ba05cc5033e0ea776ec74768da609fd6878fb1d65fe8d3
|
|
7
|
+
data.tar.gz: c6b9e54ecd0491b4ffafcf9761473a967d180e1a0bac454ea22c511f1227ba5ec6198361e28303d7db58a4b2cb6fb5f24522820b95ba6add7ada251fe240e235
|
data/README.md
CHANGED
|
@@ -22,6 +22,7 @@ A framework-agnostic Ruby SDK for the [Printavo](https://www.printavo.com) Graph
|
|
|
22
22
|
- Full [Printavo v2 GraphQL API](https://www.printavo.com/docs/api/v2) support
|
|
23
23
|
- Resource-oriented interface: `client.customers.all`, `client.orders.find(id)`
|
|
24
24
|
- Raw GraphQL access: `client.graphql.query("{ ... }")`
|
|
25
|
+
- Sanitized GraphQL response envelopes that preserve partial data and errors
|
|
25
26
|
- Rich domain models: `order.status`, `order.status?(:in_production)`, `order.customer`
|
|
26
27
|
- Rack-compatible webhook signature verification
|
|
27
28
|
- Multi-client support — no globals required
|
|
@@ -88,6 +89,60 @@ puts customer.full_name # => "Jane Smith"
|
|
|
88
89
|
puts customer.company # => "Acme Shirts"
|
|
89
90
|
```
|
|
90
91
|
|
|
92
|
+
### Bounded Contact Pages
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
page = client.contacts.page(
|
|
96
|
+
first: 50,
|
|
97
|
+
after: previous_cursor,
|
|
98
|
+
primary_only: true,
|
|
99
|
+
sort_on: Printavo::Enums::ContactSortField::CONTACT_NAME
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
page.records.each { |contact| puts contact.full_name }
|
|
103
|
+
next_cursor = page.end_cursor if page.has_next_page
|
|
104
|
+
|
|
105
|
+
# Partial GraphQL responses preserve successful records and sanitized errors.
|
|
106
|
+
warn(page.errors.inspect) unless page.success?
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`contacts.page` performs exactly one request and caps `first` at 100. It never
|
|
110
|
+
follows the next cursor automatically.
|
|
111
|
+
|
|
112
|
+
`page.response_payload` contains the exact frozen binary HTTP response body
|
|
113
|
+
captured before JSON parsing. It is intentionally omitted from ordinary
|
|
114
|
+
inspection and serialization because provider responses may contain private
|
|
115
|
+
customer data. Persist or log it only through an explicit encrypted evidence
|
|
116
|
+
boundary.
|
|
117
|
+
|
|
118
|
+
Captured contact responses can be replayed through the same SDK parser without
|
|
119
|
+
provider access:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
replayed_page = client.contacts.page_from_response_payload(
|
|
123
|
+
page.response_payload,
|
|
124
|
+
metadata: page.metadata
|
|
125
|
+
)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### GraphQL Response Envelopes
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
envelope = client.graphql.query_envelope(
|
|
132
|
+
"query Contact($id: ID!) { contact(id: $id) { id email } }",
|
|
133
|
+
variables: { id: "123" }
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
envelope.data
|
|
137
|
+
envelope.errors
|
|
138
|
+
envelope.metadata
|
|
139
|
+
envelope.partial?
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`query` and `mutate` retain their strict behavior and raise `Printavo::ApiError`
|
|
143
|
+
when GraphQL errors are present. Envelope methods preserve partial data without
|
|
144
|
+
retaining credentials, raw HTTP objects, or provider response bodies.
|
|
145
|
+
|
|
91
146
|
### Orders
|
|
92
147
|
|
|
93
148
|
```ruby
|
data/docs/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,75 @@ All notable changes to this project will be documented in this file.
|
|
|
6
6
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
7
7
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
8
8
|
|
|
9
|
+
## [0.20.0] - 2026-08-17
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `ResponseEnvelope#response_payload` exposes the exact immutable binary HTTP
|
|
13
|
+
response bytes captured before JSON parsing.
|
|
14
|
+
- `Page#response_payload` carries the exact payload for bounded contact pages.
|
|
15
|
+
- `GraphqlClient#envelope_from_response_payload` and
|
|
16
|
+
`Contacts#page_from_response_payload` rehydrate captured responses through
|
|
17
|
+
the same validation, sanitization, and page-building path without network I/O.
|
|
18
|
+
|
|
19
|
+
### Security
|
|
20
|
+
- Raw response payloads remain excluded from `ResponseEnvelope#to_h` and from
|
|
21
|
+
ordinary `ResponseEnvelope` and `Page` inspection. They may contain private
|
|
22
|
+
provider data and must be handled through an explicit evidence boundary.
|
|
23
|
+
|
|
24
|
+
## [0.19.0] - 2026-07-29
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- `Contacts#page` for one bounded contacts request with cursor, primary-contact,
|
|
28
|
+
query, and documented sort controls
|
|
29
|
+
- `GraphqlClient#query_envelope` and `#mutate_envelope` for deeply immutable
|
|
30
|
+
partial data, sanitized errors, and allowlisted response metadata
|
|
31
|
+
- `Page#errors`, `#metadata`, `#success?`, and `#partial?`
|
|
32
|
+
|
|
33
|
+
### Security
|
|
34
|
+
- Provider transport failures now raise an identifier-only `TransportError`
|
|
35
|
+
without retaining a Faraday exception cause or response body
|
|
36
|
+
- GraphQL error details retain only allowlisted fields and redact configured
|
|
37
|
+
email and token values
|
|
38
|
+
|
|
39
|
+
## [0.18.1] - 2026-04-05
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
- `examples/diagramming/workflow_diagram.rb` — generates Printavo status
|
|
43
|
+
workflows as ASCII, Mermaid, DOT, or SVG
|
|
44
|
+
- `examples/reporting/sales_report.rb` — summarizes invoice revenue for common
|
|
45
|
+
and custom reporting periods
|
|
46
|
+
- `examples/reporting/sales_tax_report.rb` — provides invoice-level sales-tax
|
|
47
|
+
estimates and fee-level taxable detail
|
|
48
|
+
- `examples/reporting/customers_export.rb` — exports customer data to CSV,
|
|
49
|
+
XLSX, XLS, vCard, HubSpot, Salesforce, Mailchimp, Constant Contact, Beehiiv,
|
|
50
|
+
Brevo, and ActiveCampaign formats
|
|
51
|
+
- `examples/reporting/outstanding_tasks.rb` — reports outstanding tasks by due
|
|
52
|
+
period and assignee and generates an HTML calendar
|
|
53
|
+
- `docs/VISUALIZATION.md` — documents the standalone workflow-diagram example,
|
|
54
|
+
supported formats, dependencies, and usage
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
- GitHub Actions now use Node.js 24-compatible `actions/checkout@v4.3.1` and
|
|
58
|
+
`actions/upload-artifact@v4.6.2`
|
|
59
|
+
|
|
60
|
+
## [0.18.0] - 2026-04-05
|
|
61
|
+
|
|
62
|
+
### Added
|
|
63
|
+
- Optional `cache:` and `default_ttl:` arguments on `Printavo::Client`; the
|
|
64
|
+
cache accepts any store implementing the `Rails.cache`-compatible
|
|
65
|
+
`fetch(key, expires_in:) { }` and `delete(key)` interface
|
|
66
|
+
- `Printavo::MemoryStore` — a thread-safe, TTL-aware in-memory cache for
|
|
67
|
+
applications that do not use Rails or an external cache
|
|
68
|
+
- Automatic GraphQL query caching with stable SHA-256 keys derived from the
|
|
69
|
+
normalized query document and variables
|
|
70
|
+
- Cache adapter and memory-store coverage in the client and GraphQL client
|
|
71
|
+
specs
|
|
72
|
+
|
|
73
|
+
### Changed
|
|
74
|
+
- GraphQL mutations always bypass the cache
|
|
75
|
+
- `docs/CACHING.md` now documents `Rails.cache`, `Printavo::MemoryStore`,
|
|
76
|
+
custom stores, cache-key generation, and mutation behavior
|
|
77
|
+
|
|
9
78
|
## [0.17.0] - 2026-04-01
|
|
10
79
|
|
|
11
80
|
### Added
|
data/lib/printavo/client.rb
CHANGED
|
@@ -47,7 +47,12 @@ module Printavo
|
|
|
47
47
|
max_retries: max_retries,
|
|
48
48
|
retry_on_rate_limit: retry_on_rate_limit
|
|
49
49
|
).build
|
|
50
|
-
@graphql = GraphqlClient.new(
|
|
50
|
+
@graphql = GraphqlClient.new(
|
|
51
|
+
connection,
|
|
52
|
+
cache: cache,
|
|
53
|
+
default_ttl: default_ttl,
|
|
54
|
+
sensitive_values: [email, token]
|
|
55
|
+
)
|
|
51
56
|
end
|
|
52
57
|
|
|
53
58
|
def account
|
data/lib/printavo/connection.rb
CHANGED
|
@@ -29,7 +29,7 @@ module Printavo
|
|
|
29
29
|
f.headers['email'] = @email
|
|
30
30
|
f.headers['token'] = @token
|
|
31
31
|
f.request :retry, **retry_options
|
|
32
|
-
f.response :json
|
|
32
|
+
f.response :json, preserve_raw: true
|
|
33
33
|
f.options.timeout = @timeout
|
|
34
34
|
f.options.open_timeout = @timeout
|
|
35
35
|
f.adapter Faraday.default_adapter
|
data/lib/printavo/errors.rb
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
query Contacts(
|
|
2
|
+
$first: Int
|
|
3
|
+
$after: String
|
|
4
|
+
$primaryOnly: Boolean
|
|
5
|
+
$query: String
|
|
6
|
+
$sortOn: ContactSortField
|
|
7
|
+
$sortDescending: Boolean
|
|
8
|
+
) {
|
|
9
|
+
contacts(
|
|
10
|
+
first: $first
|
|
11
|
+
after: $after
|
|
12
|
+
primaryOnly: $primaryOnly
|
|
13
|
+
query: $query
|
|
14
|
+
sortOn: $sortOn
|
|
15
|
+
sortDescending: $sortDescending
|
|
16
|
+
) {
|
|
17
|
+
nodes {
|
|
18
|
+
id
|
|
19
|
+
firstName
|
|
20
|
+
lastName
|
|
21
|
+
fullName
|
|
22
|
+
email
|
|
23
|
+
phone
|
|
24
|
+
fax
|
|
25
|
+
createdAt
|
|
26
|
+
updatedAt
|
|
27
|
+
customer {
|
|
28
|
+
id
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
pageInfo {
|
|
32
|
+
hasNextPage
|
|
33
|
+
endCursor
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -6,15 +6,29 @@ require 'json'
|
|
|
6
6
|
|
|
7
7
|
module Printavo
|
|
8
8
|
class GraphqlClient
|
|
9
|
+
SAFE_ERROR_EXTENSION_KEYS = %w[classification code].freeze
|
|
10
|
+
SAFE_METADATA_HEADERS = %w[
|
|
11
|
+
retry-after
|
|
12
|
+
x-request-id
|
|
13
|
+
x-ratelimit-limit
|
|
14
|
+
x-ratelimit-remaining
|
|
15
|
+
x-ratelimit-reset
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
9
18
|
# @param connection [Faraday::Connection]
|
|
10
19
|
# @param cache [#fetch, #delete, nil] any cache store implementing
|
|
11
20
|
# +fetch(key, expires_in:) { }+ and +delete(key)+,
|
|
12
21
|
# e.g. +Rails.cache+, +Printavo::MemoryStore.new+, or +nil+
|
|
13
22
|
# @param default_ttl [Integer] default TTL in seconds applied to cached queries (default: 300)
|
|
14
|
-
|
|
23
|
+
# @param sensitive_values [Array<String>] credentials to redact from provider messages
|
|
24
|
+
def initialize(connection, cache: nil, default_ttl: 300, sensitive_values: [])
|
|
15
25
|
@connection = connection
|
|
16
26
|
@cache = cache
|
|
17
27
|
@default_ttl = default_ttl
|
|
28
|
+
@sensitive_values = sensitive_values.filter_map do |value|
|
|
29
|
+
string = value.to_s
|
|
30
|
+
string unless string.empty?
|
|
31
|
+
end.freeze
|
|
18
32
|
end
|
|
19
33
|
|
|
20
34
|
# Executes a GraphQL query and returns the parsed `data` hash.
|
|
@@ -61,6 +75,40 @@ module Printavo
|
|
|
61
75
|
execute(mutation_string, variables: variables)
|
|
62
76
|
end
|
|
63
77
|
|
|
78
|
+
# Executes a GraphQL query without discarding partial data when GraphQL
|
|
79
|
+
# errors are present. HTTP and transport failures still raise their
|
|
80
|
+
# identifier-only SDK exceptions.
|
|
81
|
+
#
|
|
82
|
+
# @return [Printavo::ResponseEnvelope]
|
|
83
|
+
def query_envelope(query_string, variables: {})
|
|
84
|
+
execute_envelope(query_string, variables: variables)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Mutation equivalent of #query_envelope. Callers must decide whether a
|
|
88
|
+
# partial mutation response represents an uncertain write.
|
|
89
|
+
#
|
|
90
|
+
# @return [Printavo::ResponseEnvelope]
|
|
91
|
+
def mutate_envelope(mutation_string, variables: {})
|
|
92
|
+
execute_envelope(mutation_string, variables: variables)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Rehydrates one captured GraphQL response without performing network I/O.
|
|
96
|
+
# The exact payload remains attached to the immutable envelope while parsed
|
|
97
|
+
# data and errors follow the same validation and sanitization as live calls.
|
|
98
|
+
def envelope_from_response_payload(response_payload, metadata: {})
|
|
99
|
+
raise ArgumentError, 'response_payload must be a String' unless response_payload.is_a?(String)
|
|
100
|
+
|
|
101
|
+
payload = response_payload.b.dup.freeze
|
|
102
|
+
body = JSON.parse(payload)
|
|
103
|
+
build_envelope(
|
|
104
|
+
body: body,
|
|
105
|
+
metadata: safe_metadata_hash(metadata),
|
|
106
|
+
response_payload: payload
|
|
107
|
+
)
|
|
108
|
+
rescue JSON::ParserError
|
|
109
|
+
malformed_envelope(metadata: safe_metadata_hash(metadata), response_payload: payload)
|
|
110
|
+
end
|
|
111
|
+
|
|
64
112
|
# Iterates all pages of a paginated GraphQL query, yielding each page's
|
|
65
113
|
# nodes array. The query must accept `$first: Int` and `$after: String`
|
|
66
114
|
# variables, and the target connection must expose `nodes` and `pageInfo`.
|
|
@@ -106,28 +154,121 @@ module Printavo
|
|
|
106
154
|
end
|
|
107
155
|
|
|
108
156
|
def execute(document, variables: {})
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
157
|
+
envelope = execute_envelope(document, variables: variables)
|
|
158
|
+
return envelope.data if envelope.success?
|
|
159
|
+
|
|
160
|
+
messages = envelope.errors.map { |error| error.fetch('message') }.join(', ')
|
|
161
|
+
raise ApiError.new(messages, response: { 'errors' => envelope.errors }.freeze)
|
|
113
162
|
end
|
|
114
163
|
|
|
115
|
-
def
|
|
116
|
-
body =
|
|
164
|
+
def execute_envelope(document, variables: {})
|
|
165
|
+
response = @connection.post('') { |request| request.body = JSON.generate(query: document, variables: variables) }
|
|
166
|
+
handle_envelope(response)
|
|
167
|
+
rescue Faraday::ParsingError => e
|
|
168
|
+
handle_envelope(e.response)
|
|
169
|
+
rescue Faraday::Error
|
|
170
|
+
raise TransportError, 'Printavo transport request failed', cause: nil
|
|
171
|
+
end
|
|
117
172
|
|
|
173
|
+
def handle_envelope(response)
|
|
118
174
|
case response.status
|
|
119
175
|
when 401 then raise AuthenticationError, 'Invalid credentials — check your email and token'
|
|
120
176
|
when 429 then raise RateLimitError, 'Printavo rate limit exceeded (10 req/5 sec)'
|
|
121
177
|
when 404 then raise NotFoundError, 'Resource not found'
|
|
122
178
|
end
|
|
123
179
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
180
|
+
build_envelope(
|
|
181
|
+
body: response.body,
|
|
182
|
+
metadata: safe_metadata_hash(response.headers.to_h),
|
|
183
|
+
response_payload: raw_response_payload(response)
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def build_envelope(body:, metadata:, response_payload:)
|
|
188
|
+
return malformed_envelope(metadata: metadata, response_payload: response_payload) unless valid_envelope?(body)
|
|
189
|
+
|
|
190
|
+
ResponseEnvelope.new(
|
|
191
|
+
data: body['data'],
|
|
192
|
+
errors: sanitize_errors(body.fetch('errors', [])),
|
|
193
|
+
metadata: metadata,
|
|
194
|
+
response_payload: response_payload
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def valid_envelope?(body)
|
|
199
|
+
return false unless body.is_a?(Hash) && (body.key?('data') || body.key?('errors'))
|
|
200
|
+
return false unless body['data'].nil? || body['data'].is_a?(Hash)
|
|
201
|
+
|
|
202
|
+
errors = body.fetch('errors', [])
|
|
203
|
+
errors.is_a?(Array) && errors.all?(Hash)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def malformed_envelope(metadata:, response_payload:)
|
|
207
|
+
ResponseEnvelope.new(
|
|
208
|
+
data: nil,
|
|
209
|
+
errors: [
|
|
210
|
+
{
|
|
211
|
+
'message' => 'Malformed GraphQL response',
|
|
212
|
+
'extensions' => { 'code' => 'MALFORMED_RESPONSE' }
|
|
213
|
+
}
|
|
214
|
+
],
|
|
215
|
+
metadata: metadata,
|
|
216
|
+
response_payload: response_payload
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def raw_response_payload(response)
|
|
221
|
+
payload = response.env[:raw_body] || response.body
|
|
222
|
+
payload if payload.is_a?(String)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def sanitize_errors(errors)
|
|
226
|
+
errors.map do |error|
|
|
227
|
+
{
|
|
228
|
+
'message' => redact(error.fetch('message', 'GraphQL request failed').to_s)[0, 500],
|
|
229
|
+
'path' => sanitize_path(error['path']),
|
|
230
|
+
'locations' => sanitize_locations(error['locations']),
|
|
231
|
+
'extensions' => sanitize_extensions(error['extensions'])
|
|
232
|
+
}.compact
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def sanitize_path(path)
|
|
237
|
+
return unless path.is_a?(Array)
|
|
238
|
+
|
|
239
|
+
path.select { |part| part.is_a?(String) || part.is_a?(Integer) }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def sanitize_locations(locations)
|
|
243
|
+
return unless locations.is_a?(Array)
|
|
244
|
+
|
|
245
|
+
locations.filter_map do |location|
|
|
246
|
+
next unless location.is_a?(Hash)
|
|
247
|
+
|
|
248
|
+
line = location['line']
|
|
249
|
+
column = location['column']
|
|
250
|
+
{ 'line' => line, 'column' => column } if line.is_a?(Integer) && column.is_a?(Integer)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def sanitize_extensions(extensions)
|
|
255
|
+
return unless extensions.is_a?(Hash)
|
|
256
|
+
|
|
257
|
+
extensions.slice(*SAFE_ERROR_EXTENSION_KEYS).transform_values { |value| redact(value.to_s)[0, 100] }
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def safe_metadata_hash(value)
|
|
261
|
+
raise ArgumentError, 'metadata must be a Hash' unless value.is_a?(Hash)
|
|
262
|
+
|
|
263
|
+
headers = value.transform_keys { |key| key.to_s.downcase }
|
|
264
|
+
SAFE_METADATA_HEADERS.each_with_object({}) do |header, metadata|
|
|
265
|
+
value = headers[header]
|
|
266
|
+
metadata[header] = redact(value.to_s)[0, 100] unless value.nil?
|
|
128
267
|
end
|
|
268
|
+
end
|
|
129
269
|
|
|
130
|
-
|
|
270
|
+
def redact(value)
|
|
271
|
+
@sensitive_values.reduce(value.dup) { |result, secret| result.gsub(secret, '[FILTERED]') }
|
|
131
272
|
end
|
|
132
273
|
|
|
133
274
|
# Resolves a dot-separated path against a nested hash.
|
data/lib/printavo/page.rb
CHANGED
|
@@ -14,9 +14,32 @@ module Printavo
|
|
|
14
14
|
# client.customers.each_page(first: 10) do |records|
|
|
15
15
|
# records.each { |c| puts c.full_name }
|
|
16
16
|
# end
|
|
17
|
-
Page = Struct.new(
|
|
17
|
+
Page = Struct.new(
|
|
18
|
+
:records,
|
|
19
|
+
:has_next_page,
|
|
20
|
+
:end_cursor,
|
|
21
|
+
:errors,
|
|
22
|
+
:metadata,
|
|
23
|
+
:response_payload,
|
|
24
|
+
keyword_init: true
|
|
25
|
+
) do
|
|
26
|
+
def initialize(**attributes)
|
|
27
|
+
payload = attributes[:response_payload]
|
|
28
|
+
raise ArgumentError, 'response_payload must be a String' if payload && !payload.is_a?(String)
|
|
29
|
+
|
|
30
|
+
payload = payload.b.dup.freeze if payload
|
|
31
|
+
super(**attributes.merge(response_payload: payload))
|
|
32
|
+
end
|
|
33
|
+
|
|
18
34
|
def to_a = records
|
|
19
35
|
def size = records.size
|
|
20
36
|
def empty? = records.empty?
|
|
37
|
+
def success? = Array(errors).empty?
|
|
38
|
+
def partial? = records.any? && !success?
|
|
39
|
+
|
|
40
|
+
def inspect
|
|
41
|
+
"#<#{self.class} records=#{records.inspect} has_next_page=#{has_next_page.inspect} " \
|
|
42
|
+
"end_cursor=#{end_cursor.inspect} errors=#{errors.inspect} metadata=#{metadata.inspect}>"
|
|
43
|
+
end
|
|
21
44
|
end
|
|
22
45
|
end
|
|
@@ -4,11 +4,40 @@
|
|
|
4
4
|
module Printavo
|
|
5
5
|
module Resources
|
|
6
6
|
class Contacts < Base
|
|
7
|
+
ALL_QUERY = File.read(File.join(__dir__, '../graphql/contacts/all.graphql')).freeze
|
|
7
8
|
FIND_QUERY = File.read(File.join(__dir__, '../graphql/contacts/find.graphql')).freeze
|
|
8
9
|
CREATE_MUTATION = File.read(File.join(__dir__, '../graphql/contacts/create.graphql')).freeze
|
|
9
10
|
DELETE_MUTATION = File.read(File.join(__dir__, '../graphql/contacts/delete.graphql')).freeze
|
|
10
11
|
UPDATE_MUTATION = File.read(File.join(__dir__, '../graphql/contacts/update.graphql')).freeze
|
|
11
12
|
|
|
13
|
+
MAX_PAGE_SIZE = 100
|
|
14
|
+
PAGE_FILTERS = %i[primary_only query sort_descending sort_on].freeze
|
|
15
|
+
|
|
16
|
+
def all(first: 25, after: nil, **filters)
|
|
17
|
+
page(first: first, after: after, **filters).records
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Fetches one bounded contact page while preserving sanitized partial
|
|
21
|
+
# GraphQL errors. This method never follows the next cursor.
|
|
22
|
+
#
|
|
23
|
+
# @return [Printavo::Page]
|
|
24
|
+
def page(first: 25, after: nil, **filters)
|
|
25
|
+
validate_page!(first: first, filters: filters)
|
|
26
|
+
envelope = @graphql.query_envelope(
|
|
27
|
+
ALL_QUERY,
|
|
28
|
+
variables: contact_variables(first: first, after: after, filters: filters)
|
|
29
|
+
)
|
|
30
|
+
build_page(envelope)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Rebuilds a captured contacts page through the SDK parser without
|
|
34
|
+
# performing provider network I/O.
|
|
35
|
+
def page_from_response_payload(response_payload, metadata: {})
|
|
36
|
+
build_page(
|
|
37
|
+
@graphql.envelope_from_response_payload(response_payload, metadata: metadata)
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
12
41
|
# Finds a contact by ID.
|
|
13
42
|
#
|
|
14
43
|
# @param id [String, Integer]
|
|
@@ -57,6 +86,47 @@ module Printavo
|
|
|
57
86
|
@graphql.mutate(DELETE_MUTATION, variables: { id: id.to_s })
|
|
58
87
|
nil
|
|
59
88
|
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def validate_page!(first:, filters:)
|
|
93
|
+
unless first.is_a?(Integer) && first.between?(1, MAX_PAGE_SIZE)
|
|
94
|
+
raise ArgumentError, "first must be between 1 and #{MAX_PAGE_SIZE}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
unknown_filters = filters.keys - PAGE_FILTERS
|
|
98
|
+
raise ArgumentError, "unknown contact filters: #{unknown_filters.join(', ')}" if unknown_filters.any?
|
|
99
|
+
|
|
100
|
+
sort_on = filters[:sort_on]
|
|
101
|
+
return if sort_on.nil? || Enums::ContactSortField::ALL.include?(sort_on)
|
|
102
|
+
|
|
103
|
+
raise ArgumentError, 'sort_on must be a Printavo::Enums::ContactSortField value'
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def contact_variables(first:, after:, filters:)
|
|
107
|
+
{
|
|
108
|
+
first: first,
|
|
109
|
+
after: after,
|
|
110
|
+
primaryOnly: filters[:primary_only],
|
|
111
|
+
query: filters[:query],
|
|
112
|
+
sortOn: filters[:sort_on],
|
|
113
|
+
sortDescending: filters[:sort_descending]
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def build_page(envelope)
|
|
118
|
+
connection = envelope.data&.fetch('contacts', nil) || {}
|
|
119
|
+
records = Array(connection['nodes']).map { |attrs| Printavo::Contact.new(attrs) }
|
|
120
|
+
page_info = connection.fetch('pageInfo', {})
|
|
121
|
+
Printavo::Page.new(
|
|
122
|
+
records: records,
|
|
123
|
+
has_next_page: page_info.fetch('hasNextPage', false),
|
|
124
|
+
end_cursor: page_info['endCursor'],
|
|
125
|
+
errors: envelope.errors,
|
|
126
|
+
metadata: envelope.metadata,
|
|
127
|
+
response_payload: envelope.response_payload
|
|
128
|
+
)
|
|
129
|
+
end
|
|
60
130
|
end
|
|
61
131
|
end
|
|
62
132
|
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# lib/printavo/response_envelope.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Printavo
|
|
5
|
+
class ResponseEnvelope
|
|
6
|
+
attr_reader :data, :errors, :metadata, :response_payload
|
|
7
|
+
|
|
8
|
+
def initialize(data:, errors: [], metadata: {}, response_payload: nil)
|
|
9
|
+
@data = immutable_copy(data)
|
|
10
|
+
@errors = immutable_copy(errors)
|
|
11
|
+
@metadata = immutable_copy(metadata)
|
|
12
|
+
@response_payload = immutable_payload(response_payload)
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def success?
|
|
17
|
+
errors.empty?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def partial?
|
|
21
|
+
!data.nil? && errors.any?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
{ 'data' => data, 'errors' => errors, 'metadata' => metadata }.freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def inspect
|
|
29
|
+
"#<#{self.class} data=#{data.inspect} errors=#{errors.inspect} metadata=#{metadata.inspect}>"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def immutable_payload(value)
|
|
35
|
+
return if value.nil?
|
|
36
|
+
raise ArgumentError, 'response_payload must be a String' unless value.is_a?(String)
|
|
37
|
+
|
|
38
|
+
value.b.dup.freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def immutable_copy(value)
|
|
42
|
+
case value
|
|
43
|
+
when Hash
|
|
44
|
+
value.each_with_object({}) do |(key, item), copy|
|
|
45
|
+
copy[key.to_s.freeze] = immutable_copy(item)
|
|
46
|
+
end.freeze
|
|
47
|
+
when Array
|
|
48
|
+
value.map { |item| immutable_copy(item) }.freeze
|
|
49
|
+
when String
|
|
50
|
+
value.dup.freeze
|
|
51
|
+
else
|
|
52
|
+
value
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/printavo/version.rb
CHANGED
data/lib/printavo.rb
CHANGED
|
@@ -10,6 +10,7 @@ require_relative 'printavo/config'
|
|
|
10
10
|
require_relative 'printavo/connection'
|
|
11
11
|
require_relative 'printavo/enums'
|
|
12
12
|
require_relative 'printavo/errors'
|
|
13
|
+
require_relative 'printavo/response_envelope'
|
|
13
14
|
require_relative 'printavo/graphql_client'
|
|
14
15
|
require_relative 'printavo/memory_store'
|
|
15
16
|
require_relative 'printavo/page'
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: printavo-ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.20.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stan Carver II
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-29 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|
|
@@ -16,14 +16,14 @@ dependencies:
|
|
|
16
16
|
requirements:
|
|
17
17
|
- - "~>"
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '2.
|
|
19
|
+
version: '2.14'
|
|
20
20
|
type: :runtime
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
24
|
- - "~>"
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '2.
|
|
26
|
+
version: '2.14'
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
28
|
name: faraday-retry
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -320,6 +320,7 @@ files:
|
|
|
320
320
|
- lib/printavo/graphql/approval_requests/unapprove.graphql
|
|
321
321
|
- lib/printavo/graphql/categories/all.graphql
|
|
322
322
|
- lib/printavo/graphql/categories/find.graphql
|
|
323
|
+
- lib/printavo/graphql/contacts/all.graphql
|
|
323
324
|
- lib/printavo/graphql/contacts/create.graphql
|
|
324
325
|
- lib/printavo/graphql/contacts/delete.graphql
|
|
325
326
|
- lib/printavo/graphql/contacts/find.graphql
|
|
@@ -544,6 +545,7 @@ files:
|
|
|
544
545
|
- lib/printavo/resources/types_of_work.rb
|
|
545
546
|
- lib/printavo/resources/users.rb
|
|
546
547
|
- lib/printavo/resources/vendors.rb
|
|
548
|
+
- lib/printavo/response_envelope.rb
|
|
547
549
|
- lib/printavo/version.rb
|
|
548
550
|
- lib/printavo/webhooks.rb
|
|
549
551
|
homepage: https://github.com/scarver2/printavo-ruby
|