printavo-ruby 0.19.0 → 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: 48b453128217df2c6051caddc404b2623acf14aa49f1c7ef9aab789359245ec4
4
- data.tar.gz: e39850f5537b1a5362f01ac910e1501d3bf473470dca5cfce5465b3352fbb7ee
3
+ metadata.gz: 6ad593920d96da45a59747ebfaab7fce5d5a1b411e0f6afa781bca1fefcca158
4
+ data.tar.gz: 6eb415c85c7566986565daddcd6ebd5065d87af95680599ba0b4717892de20e0
5
5
  SHA512:
6
- metadata.gz: 4a5effd363bbefcaff3ac665457d3b099f5f338911583e29885a4be4c2308ea912f753157a13d088b8b3e91105ea9b697fd78e41d5a02ab55fecebb4174d4f2e
7
- data.tar.gz: 80760572b15654acd78928d16ef4e639961dae9ce4a95f9d812660a8612d0aeaf3205f5ac51f3bf6a4648e2bea00b37939afa077354bbf6baca540fbb4a18a86
6
+ metadata.gz: d95dc0294fe872e6db56d48ab7309c668f24edc5caadf22a5f1333407cac2f31d6845fef845c0cd126ba05cc5033e0ea776ec74768da609fd6878fb1d65fe8d3
7
+ data.tar.gz: c6b9e54ecd0491b4ffafcf9761473a967d180e1a0bac454ea22c511f1227ba5ec6198361e28303d7db58a4b2cb6fb5f24522820b95ba6add7ada251fe240e235
data/README.md CHANGED
@@ -109,6 +109,22 @@ warn(page.errors.inspect) unless page.success?
109
109
  `contacts.page` performs exactly one request and caps `first` at 100. It never
110
110
  follows the next cursor automatically.
111
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
+
112
128
  ### GraphQL Response Envelopes
113
129
 
114
130
  ```ruby
data/docs/CHANGELOG.md CHANGED
@@ -6,6 +6,21 @@ 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
+
9
24
  ## [0.19.0] - 2026-07-29
10
25
 
11
26
  ### Added
@@ -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
@@ -92,6 +92,23 @@ module Printavo
92
92
  execute_envelope(mutation_string, variables: variables)
93
93
  end
94
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
+
95
112
  # Iterates all pages of a paginated GraphQL query, yielding each page's
96
113
  # nodes array. The query must accept `$first: Int` and `$after: String`
97
114
  # variables, and the target connection must expose `nodes` and `pageInfo`.
@@ -145,10 +162,10 @@ module Printavo
145
162
  end
146
163
 
147
164
  def execute_envelope(document, variables: {})
148
- response = @connection.post('') do |req|
149
- req.body = JSON.generate(query: document, variables: variables)
150
- end
165
+ response = @connection.post('') { |request| request.body = JSON.generate(query: document, variables: variables) }
151
166
  handle_envelope(response)
167
+ rescue Faraday::ParsingError => e
168
+ handle_envelope(e.response)
152
169
  rescue Faraday::Error
153
170
  raise TransportError, 'Printavo transport request failed', cause: nil
154
171
  end
@@ -160,26 +177,33 @@ module Printavo
160
177
  when 404 then raise NotFoundError, 'Resource not found'
161
178
  end
162
179
 
163
- body = response.body
164
- return malformed_envelope(response) unless valid_envelope?(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)
165
189
 
166
190
  ResponseEnvelope.new(
167
191
  data: body['data'],
168
192
  errors: sanitize_errors(body.fetch('errors', [])),
169
- metadata: safe_metadata(response)
193
+ metadata: metadata,
194
+ response_payload: response_payload
170
195
  )
171
196
  end
172
197
 
173
198
  def valid_envelope?(body)
174
- return false unless body.is_a?(Hash)
175
- return false unless body.key?('data') || body.key?('errors')
199
+ return false unless body.is_a?(Hash) && (body.key?('data') || body.key?('errors'))
176
200
  return false unless body['data'].nil? || body['data'].is_a?(Hash)
177
201
 
178
202
  errors = body.fetch('errors', [])
179
203
  errors.is_a?(Array) && errors.all?(Hash)
180
204
  end
181
205
 
182
- def malformed_envelope(response)
206
+ def malformed_envelope(metadata:, response_payload:)
183
207
  ResponseEnvelope.new(
184
208
  data: nil,
185
209
  errors: [
@@ -188,10 +212,16 @@ module Printavo
188
212
  'extensions' => { 'code' => 'MALFORMED_RESPONSE' }
189
213
  }
190
214
  ],
191
- metadata: safe_metadata(response)
215
+ metadata: metadata,
216
+ response_payload: response_payload
192
217
  )
193
218
  end
194
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
+
195
225
  def sanitize_errors(errors)
196
226
  errors.map do |error|
197
227
  {
@@ -227,8 +257,10 @@ module Printavo
227
257
  extensions.slice(*SAFE_ERROR_EXTENSION_KEYS).transform_values { |value| redact(value.to_s)[0, 100] }
228
258
  end
229
259
 
230
- def safe_metadata(response)
231
- headers = response.headers.to_h.transform_keys(&:downcase)
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 }
232
264
  SAFE_METADATA_HEADERS.each_with_object({}) do |header, metadata|
233
265
  value = headers[header]
234
266
  metadata[header] = redact(value.to_s)[0, 100] unless value.nil?
data/lib/printavo/page.rb CHANGED
@@ -14,11 +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, :errors, :metadata, 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?
21
37
  def success? = Array(errors).empty?
22
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
23
44
  end
24
45
  end
@@ -30,6 +30,14 @@ module Printavo
30
30
  build_page(envelope)
31
31
  end
32
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
+
33
41
  # Finds a contact by ID.
34
42
  #
35
43
  # @param id [String, Integer]
@@ -115,7 +123,8 @@ module Printavo
115
123
  has_next_page: page_info.fetch('hasNextPage', false),
116
124
  end_cursor: page_info['endCursor'],
117
125
  errors: envelope.errors,
118
- metadata: envelope.metadata
126
+ metadata: envelope.metadata,
127
+ response_payload: envelope.response_payload
119
128
  )
120
129
  end
121
130
  end
@@ -3,12 +3,13 @@
3
3
 
4
4
  module Printavo
5
5
  class ResponseEnvelope
6
- attr_reader :data, :errors, :metadata
6
+ attr_reader :data, :errors, :metadata, :response_payload
7
7
 
8
- def initialize(data:, errors: [], metadata: {})
8
+ def initialize(data:, errors: [], metadata: {}, response_payload: nil)
9
9
  @data = immutable_copy(data)
10
10
  @errors = immutable_copy(errors)
11
11
  @metadata = immutable_copy(metadata)
12
+ @response_payload = immutable_payload(response_payload)
12
13
  freeze
13
14
  end
14
15
 
@@ -24,8 +25,19 @@ module Printavo
24
25
  { 'data' => data, 'errors' => errors, 'metadata' => metadata }.freeze
25
26
  end
26
27
 
28
+ def inspect
29
+ "#<#{self.class} data=#{data.inspect} errors=#{errors.inspect} metadata=#{metadata.inspect}>"
30
+ end
31
+
27
32
  private
28
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
+
29
41
  def immutable_copy(value)
30
42
  case value
31
43
  when Hash
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Printavo
5
- VERSION = '0.19.0'
5
+ VERSION = '0.20.0'
6
6
  end
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.19.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-07-30 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