printavo-ruby 0.18.1 → 0.19.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: 48b453128217df2c6051caddc404b2623acf14aa49f1c7ef9aab789359245ec4
4
+ data.tar.gz: e39850f5537b1a5362f01ac910e1501d3bf473470dca5cfce5465b3352fbb7ee
5
5
  SHA512:
6
- metadata.gz: fdd286fc6a29e0313aed8f7ebbea302f7737e17f44144a524396eadcde292d70d9a87b610d22ec3bd692a9a93572d7bb844c861c71dd1e87435c59a3cd465ade
7
- data.tar.gz: 29719257b52dfd20f4f9da862e3ccca5df84f8dc64dfb564421a73cbe1b88e46e2a5b36891066ed1e43607e30a3e9a9c6ceecdb3697ac85ce53c2eb1a17b9afa
6
+ metadata.gz: 4a5effd363bbefcaff3ac665457d3b099f5f338911583e29885a4be4c2308ea912f753157a13d088b8b3e91105ea9b697fd78e41d5a02ab55fecebb4174d4f2e
7
+ data.tar.gz: 80760572b15654acd78928d16ef4e639961dae9ce4a95f9d812660a8612d0aeaf3205f5ac51f3bf6a4648e2bea00b37939afa077354bbf6baca540fbb4a18a86
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,44 @@ 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
+ ### GraphQL Response Envelopes
113
+
114
+ ```ruby
115
+ envelope = client.graphql.query_envelope(
116
+ "query Contact($id: ID!) { contact(id: $id) { id email } }",
117
+ variables: { id: "123" }
118
+ )
119
+
120
+ envelope.data
121
+ envelope.errors
122
+ envelope.metadata
123
+ envelope.partial?
124
+ ```
125
+
126
+ `query` and `mutate` retain their strict behavior and raise `Printavo::ApiError`
127
+ when GraphQL errors are present. Envelope methods preserve partial data without
128
+ retaining credentials, raw HTTP objects, or provider response bodies.
129
+
91
130
  ### Orders
92
131
 
93
132
  ```ruby
data/docs/CHANGELOG.md CHANGED
@@ -6,6 +6,60 @@ 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.19.0] - 2026-07-29
10
+
11
+ ### Added
12
+ - `Contacts#page` for one bounded contacts request with cursor, primary-contact,
13
+ query, and documented sort controls
14
+ - `GraphqlClient#query_envelope` and `#mutate_envelope` for deeply immutable
15
+ partial data, sanitized errors, and allowlisted response metadata
16
+ - `Page#errors`, `#metadata`, `#success?`, and `#partial?`
17
+
18
+ ### Security
19
+ - Provider transport failures now raise an identifier-only `TransportError`
20
+ without retaining a Faraday exception cause or response body
21
+ - GraphQL error details retain only allowlisted fields and redact configured
22
+ email and token values
23
+
24
+ ## [0.18.1] - 2026-04-05
25
+
26
+ ### Added
27
+ - `examples/diagramming/workflow_diagram.rb` — generates Printavo status
28
+ workflows as ASCII, Mermaid, DOT, or SVG
29
+ - `examples/reporting/sales_report.rb` — summarizes invoice revenue for common
30
+ and custom reporting periods
31
+ - `examples/reporting/sales_tax_report.rb` — provides invoice-level sales-tax
32
+ estimates and fee-level taxable detail
33
+ - `examples/reporting/customers_export.rb` — exports customer data to CSV,
34
+ XLSX, XLS, vCard, HubSpot, Salesforce, Mailchimp, Constant Contact, Beehiiv,
35
+ Brevo, and ActiveCampaign formats
36
+ - `examples/reporting/outstanding_tasks.rb` — reports outstanding tasks by due
37
+ period and assignee and generates an HTML calendar
38
+ - `docs/VISUALIZATION.md` — documents the standalone workflow-diagram example,
39
+ supported formats, dependencies, and usage
40
+
41
+ ### Changed
42
+ - GitHub Actions now use Node.js 24-compatible `actions/checkout@v4.3.1` and
43
+ `actions/upload-artifact@v4.6.2`
44
+
45
+ ## [0.18.0] - 2026-04-05
46
+
47
+ ### Added
48
+ - Optional `cache:` and `default_ttl:` arguments on `Printavo::Client`; the
49
+ cache accepts any store implementing the `Rails.cache`-compatible
50
+ `fetch(key, expires_in:) { }` and `delete(key)` interface
51
+ - `Printavo::MemoryStore` — a thread-safe, TTL-aware in-memory cache for
52
+ applications that do not use Rails or an external cache
53
+ - Automatic GraphQL query caching with stable SHA-256 keys derived from the
54
+ normalized query document and variables
55
+ - Cache adapter and memory-store coverage in the client and GraphQL client
56
+ specs
57
+
58
+ ### Changed
59
+ - GraphQL mutations always bypass the cache
60
+ - `docs/CACHING.md` now documents `Rails.cache`, `Printavo::MemoryStore`,
61
+ custom stores, cache-key generation, and mutation behavior
62
+
9
63
  ## [0.17.0] - 2026-04-01
10
64
 
11
65
  ### 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
@@ -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,23 @@ 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
+
64
95
  # Iterates all pages of a paginated GraphQL query, yielding each page's
65
96
  # nodes array. The query must accept `$first: Int` and `$after: String`
66
97
  # variables, and the target connection must expose `nodes` and `pageInfo`.
@@ -106,28 +137,106 @@ module Printavo
106
137
  end
107
138
 
108
139
  def execute(document, variables: {})
140
+ envelope = execute_envelope(document, variables: variables)
141
+ return envelope.data if envelope.success?
142
+
143
+ messages = envelope.errors.map { |error| error.fetch('message') }.join(', ')
144
+ raise ApiError.new(messages, response: { 'errors' => envelope.errors }.freeze)
145
+ end
146
+
147
+ def execute_envelope(document, variables: {})
109
148
  response = @connection.post('') do |req|
110
149
  req.body = JSON.generate(query: document, variables: variables)
111
150
  end
112
- handle_response(response)
151
+ handle_envelope(response)
152
+ rescue Faraday::Error
153
+ raise TransportError, 'Printavo transport request failed', cause: nil
113
154
  end
114
155
 
115
- def handle_response(response)
116
- body = response.body
117
-
156
+ def handle_envelope(response)
118
157
  case response.status
119
158
  when 401 then raise AuthenticationError, 'Invalid credentials — check your email and token'
120
159
  when 429 then raise RateLimitError, 'Printavo rate limit exceeded (10 req/5 sec)'
121
160
  when 404 then raise NotFoundError, 'Resource not found'
122
161
  end
123
162
 
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)
163
+ body = response.body
164
+ return malformed_envelope(response) unless valid_envelope?(body)
165
+
166
+ ResponseEnvelope.new(
167
+ data: body['data'],
168
+ errors: sanitize_errors(body.fetch('errors', [])),
169
+ metadata: safe_metadata(response)
170
+ )
171
+ end
172
+
173
+ def valid_envelope?(body)
174
+ return false unless body.is_a?(Hash)
175
+ return false unless body.key?('data') || body.key?('errors')
176
+ return false unless body['data'].nil? || body['data'].is_a?(Hash)
177
+
178
+ errors = body.fetch('errors', [])
179
+ errors.is_a?(Array) && errors.all?(Hash)
180
+ end
181
+
182
+ def malformed_envelope(response)
183
+ ResponseEnvelope.new(
184
+ data: nil,
185
+ errors: [
186
+ {
187
+ 'message' => 'Malformed GraphQL response',
188
+ 'extensions' => { 'code' => 'MALFORMED_RESPONSE' }
189
+ }
190
+ ],
191
+ metadata: safe_metadata(response)
192
+ )
193
+ end
194
+
195
+ def sanitize_errors(errors)
196
+ errors.map do |error|
197
+ {
198
+ 'message' => redact(error.fetch('message', 'GraphQL request failed').to_s)[0, 500],
199
+ 'path' => sanitize_path(error['path']),
200
+ 'locations' => sanitize_locations(error['locations']),
201
+ 'extensions' => sanitize_extensions(error['extensions'])
202
+ }.compact
203
+ end
204
+ end
205
+
206
+ def sanitize_path(path)
207
+ return unless path.is_a?(Array)
208
+
209
+ path.select { |part| part.is_a?(String) || part.is_a?(Integer) }
210
+ end
211
+
212
+ def sanitize_locations(locations)
213
+ return unless locations.is_a?(Array)
214
+
215
+ locations.filter_map do |location|
216
+ next unless location.is_a?(Hash)
217
+
218
+ line = location['line']
219
+ column = location['column']
220
+ { 'line' => line, 'column' => column } if line.is_a?(Integer) && column.is_a?(Integer)
221
+ end
222
+ end
223
+
224
+ def sanitize_extensions(extensions)
225
+ return unless extensions.is_a?(Hash)
226
+
227
+ extensions.slice(*SAFE_ERROR_EXTENSION_KEYS).transform_values { |value| redact(value.to_s)[0, 100] }
228
+ end
229
+
230
+ def safe_metadata(response)
231
+ headers = response.headers.to_h.transform_keys(&:downcase)
232
+ SAFE_METADATA_HEADERS.each_with_object({}) do |header, metadata|
233
+ value = headers[header]
234
+ metadata[header] = redact(value.to_s)[0, 100] unless value.nil?
128
235
  end
236
+ end
129
237
 
130
- body.is_a?(Hash) ? body['data'] : body
238
+ def redact(value)
239
+ @sensitive_values.reduce(value.dup) { |result, secret| result.gsub(secret, '[FILTERED]') }
131
240
  end
132
241
 
133
242
  # Resolves a dot-separated path against a nested hash.
data/lib/printavo/page.rb CHANGED
@@ -14,9 +14,11 @@ 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(:records, :has_next_page, :end_cursor, :errors, :metadata, keyword_init: true) do
18
18
  def to_a = records
19
19
  def size = records.size
20
20
  def empty? = records.empty?
21
+ def success? = Array(errors).empty?
22
+ def partial? = records.any? && !success?
21
23
  end
22
24
  end
@@ -4,11 +4,32 @@
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
+
12
33
  # Finds a contact by ID.
13
34
  #
14
35
  # @param id [String, Integer]
@@ -57,6 +78,46 @@ module Printavo
57
78
  @graphql.mutate(DELETE_MUTATION, variables: { id: id.to_s })
58
79
  nil
59
80
  end
81
+
82
+ private
83
+
84
+ def validate_page!(first:, filters:)
85
+ unless first.is_a?(Integer) && first.between?(1, MAX_PAGE_SIZE)
86
+ raise ArgumentError, "first must be between 1 and #{MAX_PAGE_SIZE}"
87
+ end
88
+
89
+ unknown_filters = filters.keys - PAGE_FILTERS
90
+ raise ArgumentError, "unknown contact filters: #{unknown_filters.join(', ')}" if unknown_filters.any?
91
+
92
+ sort_on = filters[:sort_on]
93
+ return if sort_on.nil? || Enums::ContactSortField::ALL.include?(sort_on)
94
+
95
+ raise ArgumentError, 'sort_on must be a Printavo::Enums::ContactSortField value'
96
+ end
97
+
98
+ def contact_variables(first:, after:, filters:)
99
+ {
100
+ first: first,
101
+ after: after,
102
+ primaryOnly: filters[:primary_only],
103
+ query: filters[:query],
104
+ sortOn: filters[:sort_on],
105
+ sortDescending: filters[:sort_descending]
106
+ }
107
+ end
108
+
109
+ def build_page(envelope)
110
+ connection = envelope.data&.fetch('contacts', nil) || {}
111
+ records = Array(connection['nodes']).map { |attrs| Printavo::Contact.new(attrs) }
112
+ page_info = connection.fetch('pageInfo', {})
113
+ Printavo::Page.new(
114
+ records: records,
115
+ has_next_page: page_info.fetch('hasNextPage', false),
116
+ end_cursor: page_info['endCursor'],
117
+ errors: envelope.errors,
118
+ metadata: envelope.metadata
119
+ )
120
+ end
60
121
  end
61
122
  end
62
123
  end
@@ -0,0 +1,44 @@
1
+ # lib/printavo/response_envelope.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Printavo
5
+ class ResponseEnvelope
6
+ attr_reader :data, :errors, :metadata
7
+
8
+ def initialize(data:, errors: [], metadata: {})
9
+ @data = immutable_copy(data)
10
+ @errors = immutable_copy(errors)
11
+ @metadata = immutable_copy(metadata)
12
+ freeze
13
+ end
14
+
15
+ def success?
16
+ errors.empty?
17
+ end
18
+
19
+ def partial?
20
+ !data.nil? && errors.any?
21
+ end
22
+
23
+ def to_h
24
+ { 'data' => data, 'errors' => errors, 'metadata' => metadata }.freeze
25
+ end
26
+
27
+ private
28
+
29
+ def immutable_copy(value)
30
+ case value
31
+ when Hash
32
+ value.each_with_object({}) do |(key, item), copy|
33
+ copy[key.to_s.freeze] = immutable_copy(item)
34
+ end.freeze
35
+ when Array
36
+ value.map { |item| immutable_copy(item) }.freeze
37
+ when String
38
+ value.dup.freeze
39
+ else
40
+ value
41
+ end
42
+ end
43
+ end
44
+ 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.19.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.19.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-07-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -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