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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d2291442eb2c04c3ead1543773bcd188723de181093941cdc8519681b5b43e4b
4
- data.tar.gz: 00274b8c115044888d4099465521acf974c0356261130eb6aacaa21f1d4aa5ce
3
+ metadata.gz: 6ad593920d96da45a59747ebfaab7fce5d5a1b411e0f6afa781bca1fefcca158
4
+ data.tar.gz: 6eb415c85c7566986565daddcd6ebd5065d87af95680599ba0b4717892de20e0
5
5
  SHA512:
6
- metadata.gz: fdd286fc6a29e0313aed8f7ebbea302f7737e17f44144a524396eadcde292d70d9a87b610d22ec3bd692a9a93572d7bb844c861c71dd1e87435c59a3cd465ade
7
- data.tar.gz: 29719257b52dfd20f4f9da862e3ccca5df84f8dc64dfb564421a73cbe1b88e46e2a5b36891066ed1e43607e30a3e9a9c6ceecdb3697ac85ce53c2eb1a17b9afa
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
@@ -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(connection, cache: cache, default_ttl: default_ttl)
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
@@ -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
@@ -10,6 +10,8 @@ module Printavo
10
10
 
11
11
  class NotFoundError < Error; end
12
12
 
13
+ class TransportError < Error; end
14
+
13
15
  class ApiError < Error
14
16
  attr_reader :response
15
17
 
@@ -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
- def initialize(connection, cache: nil, default_ttl: 300)
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
- response = @connection.post('') do |req|
110
- req.body = JSON.generate(query: document, variables: variables)
111
- end
112
- handle_response(response)
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 handle_response(response)
116
- body = response.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
- errors = body.is_a?(Hash) ? body['errors'] : nil
125
- if errors&.any?
126
- messages = errors.map { |e| e['message'] }.join(', ')
127
- raise ApiError.new(messages, response: body)
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
- body.is_a?(Hash) ? body['data'] : body
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(:records, :has_next_page, :end_cursor, keyword_init: true) do
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
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Printavo
5
- VERSION = '0.18.1'
5
+ VERSION = '0.20.0'
6
6
  end
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.18.1
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-04-05 00:00:00.000000000 Z
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.0'
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.0'
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