booqable 1.2.0 → 2.0.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: ce7d8b0be4851e4e78229078331a2f73cbe7cb7702c1555df9dec0903be28d0e
4
- data.tar.gz: c79ed318eb3a38534291ef5e4abeb6a5749c7a2684bcd1befbf43d6f7f1d0e8a
3
+ metadata.gz: ee065493e3d48d59edfb13552c3dd8dbce2d800ecfb9d3c8230f6f3c52345be0
4
+ data.tar.gz: a9fd9934fd93f8504ca1a58e2cab82e09ab1f4f9f2da9fb4a30033e3f84bfe16
5
5
  SHA512:
6
- metadata.gz: 9d6dead61b0b4bfd8374c9b51aec7dccc724988b5417f9585018ee549840ee207756642b38aedbfcf4fdb4e313f9764fdc64b1d1d6e7593c057edb20d6095afc
7
- data.tar.gz: c5e34b3a736ab3bdca397dfd1245d07b8dd8e246e32d4d0f24e097c41f52d47307c645a547592e60b90dc27f98e2e817193a1059f2696c0a726316c0693c1f88
6
+ metadata.gz: 4babbce96eead5118e72cf700490aa703fb221730a7bd601f23d229cad34f63c91b59f6a7d955e733b0ce111359eb1c8f4b5e54e3834647952c44baf6e222422
7
+ data.tar.gz: fe5bd8c6bc0150c6a61f45e72de5edfc35ae33335d7bc2fa514a31650a20e44ccbd4dfa8fe3df5a9d7bf22915148c4e0bc7a259c9620e316220b66f36990db7d
data/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## [2.0.0] - 2026-07-14
2
+
3
+ - **Breaking:** reading an attribute that is absent from an API payload now
4
+ raises `Booqable::MissingAttribute` (a `NoMethodError` subclass) instead of
5
+ silently returning nil.
6
+ - Support Ruby 4.0
7
+ - Require `cgi` and declare it as a runtime dependency. Fixes
8
+ `undefined method 'parse' for class CGI` when handling `invalid_grant`
9
+ OAuth errors in apps that don't load the full cgi library themselves
10
+ (e.g. Rails 8.1+, which only loads `cgi/escape`).
11
+
12
+ ## [1.2.1] - 2026-06-10
13
+
14
+ - Require `oauth2 >= 2.0.22` to address GHSA-pp92-crg2-gfv9, where a
15
+ protocol-relative redirect `Location` could override the request authority and
16
+ leak the bearer `Authorization` header to an attacker-controlled host.
17
+
18
+
1
19
  ## [1.2.0] - 2026-05-27
2
20
 
3
21
  - Add optional `around_refresh_token` configuration. When provided, the OAuth
data/README.md CHANGED
@@ -406,6 +406,33 @@ customer = client.parse_resource(payload)
406
406
 
407
407
  `deserialize_resource` is available as an alias for `parse_resource`.
408
408
 
409
+ ## Strict attribute reads
410
+
411
+ Reading an attribute that is absent from the API payload raises
412
+ `Booqable::MissingAttribute` instead of silently returning nil, so typos and
413
+ renamed API fields fail loudly:
414
+
415
+ ```ruby
416
+ customer.name # => "John Doe"
417
+ customer.full_name # raises Booqable::MissingAttribute (key absent from payload)
418
+ ```
419
+
420
+ An attribute that is present in the payload with a null value still returns
421
+ nil — only absent keys raise:
422
+
423
+ ```ruby
424
+ order.customer # => nil when the payload contains "customer": null
425
+ ```
426
+
427
+ `Booqable::MissingAttribute` subclasses `NoMethodError`, so generic rescues
428
+ keep working. To probe for an attribute that may be absent, use hash-style
429
+ access or `key?`:
430
+
431
+ ```ruby
432
+ order[:customer] # => nil when the key is absent (lenient probe)
433
+ order.key?(:customer) # => false when the key is absent
434
+ ```
435
+
409
436
  ## Advanced usage
410
437
 
411
438
  ### Custom middleware
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "did_you_mean"
4
+
3
5
  module Booqable
4
6
  # Client for the Booqable API
5
7
  #
@@ -40,11 +42,22 @@ module Booqable
40
42
  access_token
41
43
  ]
42
44
 
45
+ # Accepted aliases for configuration options, mapping the alias to its
46
+ # canonical {Booqable::Configurable} key.
47
+ OPTION_ALIASES = {
48
+ skip_retries: :no_retries
49
+ }.freeze
50
+
43
51
  # Initialize a new Client
44
52
  #
45
53
  # @param options [Hash] Configuration options for the client
46
54
  # @see Booqable::Configurable For a complete list of supported configuration options
47
55
  def initialize(options = {})
56
+ options = normalize_aliases(options)
57
+
58
+ unknown_keys = options.keys.map(&:to_sym) - Booqable::Configurable.keys
59
+ raise ArgumentError, unknown_options_message(unknown_keys) unless unknown_keys.empty?
60
+
48
61
  # Use options passed in, but fall back to module defaults
49
62
  #
50
63
  # This may look like a `.keys.each` which should be replaced with `#each_key`, but
@@ -94,5 +107,34 @@ module Booqable
94
107
 
95
108
  inspected
96
109
  end
110
+
111
+ private
112
+
113
+ # Rewrite any aliased option keys to their canonical configuration key.
114
+ #
115
+ # @param options [Hash] options as passed to {#initialize}
116
+ # @return [Hash] options with aliases replaced by their canonical keys
117
+ def normalize_aliases(options)
118
+ options.to_h do |key, value|
119
+ [ OPTION_ALIASES.fetch(key.to_sym, key), value ]
120
+ end
121
+ end
122
+
123
+ # Build an error message for unknown configuration options, suggesting
124
+ # similarly-named valid options via did_you_mean when one is close enough.
125
+ #
126
+ # @param unknown_keys [Array<Symbol>] options that aren't valid config keys
127
+ # @return [String]
128
+ def unknown_options_message(unknown_keys)
129
+ dictionary = Booqable::Configurable.keys + OPTION_ALIASES.keys
130
+ spell_checker = DidYouMean::SpellChecker.new(dictionary: dictionary)
131
+
132
+ message = "unknown configuration option(s): #{unknown_keys.join(", ")}"
133
+
134
+ suggestions = unknown_keys.flat_map { |key| spell_checker.correct(key) }.uniq
135
+ message += ". Did you mean: #{suggestions.join(", ")}?" unless suggestions.empty?
136
+
137
+ message
138
+ end
97
139
  end
98
140
  end
@@ -421,6 +421,50 @@ module Booqable
421
421
  # and body matches 'read-only'
422
422
  class ReadOnlyMode < ServerError; end
423
423
 
424
+ # Raised when reading an attribute that is absent from an API resource
425
+ #
426
+ # Sawyer normally answers reads of absent attributes with nil, which turns
427
+ # typos and renamed API fields (e.g. `time_zone` vs `default_timezone`)
428
+ # into silent data bugs. Resources created by this gem raise this error
429
+ # instead — see {Booqable::StrictAttributes}.
430
+ #
431
+ # Attributes that are present in the payload with a null value still
432
+ # return nil; only reads of keys that are absent from the payload raise.
433
+ #
434
+ # This inherits from NoMethodError (not Booqable::Error) because an absent
435
+ # attribute is a programming error in the caller, not an API failure:
436
+ #
437
+ # * generic `rescue NoMethodError` / `rescue StandardError` code keeps working
438
+ # * ActiveSupport's `resource.try(:foo)` stays a safe probe: it returns nil
439
+ # without calling, since respond_to? is false for absent attributes
440
+ # (the bang variant `try!(:foo)` raises)
441
+ # * hash-style access stays a lenient, explicit probe: `resource[:foo]`
442
+ # returns nil for absent keys because Sawyer rescues NoMethodError there
443
+ class MissingAttribute < NoMethodError
444
+ # Initialize a new MissingAttribute error
445
+ #
446
+ # @param attribute_name [Symbol] Name of the absent attribute that was read
447
+ # @param resource [Sawyer::Resource] Resource the attribute was read from
448
+ def initialize(attribute_name, resource)
449
+ super(build_message(attribute_name, resource), attribute_name)
450
+ end
451
+
452
+ private
453
+
454
+ def build_message(attribute_name, resource)
455
+ message = +"undefined attribute `#{attribute_name}` for #{resource_description(resource)}. "
456
+ message << "The attribute is absent from the API payload "
457
+ message << "(attributes present with a null value return nil). "
458
+ message << "Available attributes: #{resource.attrs.keys.sort.join(", ")}"
459
+ message
460
+ end
461
+
462
+ def resource_description(resource)
463
+ type = resource.attrs[:type]
464
+ type.is_a?(String) ? "a Booqable #{type} resource" : "a Booqable resource"
465
+ end
466
+ end
467
+
424
468
  # Raised when Booqable configuration is invalid
425
469
  class ConfigArgumentError < ArgumentError; end
426
470
 
data/lib/booqable/http.rb CHANGED
@@ -243,15 +243,17 @@ module Booqable
243
243
 
244
244
  # Get or create the Sawyer agent for API requests
245
245
  #
246
- # Returns a memoized Sawyer::Agent configured with the API endpoint,
247
- # serializer, and optional logging. Sawyer handles the low-level HTTP
248
- # communication and response parsing.
246
+ # Returns a memoized Booqable::SawyerAgent configured with the API
247
+ # endpoint, serializer, and optional logging. Sawyer handles the
248
+ # low-level HTTP communication and response parsing. Resources created
249
+ # through this agent raise {Booqable::MissingAttribute} when an absent
250
+ # attribute is read (see {Booqable::StrictAttributes}).
249
251
  #
250
- # @return [Sawyer::Agent] HTTP agent instance
252
+ # @return [Booqable::SawyerAgent] HTTP agent instance
251
253
  # @api private
252
254
  def agent
253
- @agent ||= Sawyer::Agent.new(api_endpoint,
254
- sawyer_options) do |agent|
255
+ @agent ||= Booqable::SawyerAgent.new(api_endpoint,
256
+ sawyer_options) do |agent|
255
257
  agent.response :logger, logger, bodies: true if logger
256
258
  end
257
259
  end
@@ -69,11 +69,12 @@ module Booqable
69
69
  #
70
70
  # The agent URL is a placeholder - we don't make any HTTP requests.
71
71
  # We just need the agent to create Sawyer::Resource objects that
72
- # provide dot-notation attribute access.
72
+ # provide dot-notation attribute access. Using Booqable::SawyerAgent
73
+ # makes attribute reads strict (see {Booqable::StrictAttributes}).
73
74
  #
74
- # @return [Sawyer::Agent]
75
+ # @return [Booqable::SawyerAgent]
75
76
  def sawyer_agent
76
- @sawyer_agent ||= Sawyer::Agent.new("https://example.com") do |http|
77
+ @sawyer_agent ||= Booqable::SawyerAgent.new("https://example.com") do |http|
77
78
  http.headers[:content_type] = "application/json"
78
79
  end
79
80
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Booqable
4
+ # Sawyer::Agent used for every resource this gem creates
5
+ #
6
+ # Behaves exactly like Sawyer::Agent. It exists as a marker so that
7
+ # {Booqable::StrictAttributes} can tell resources created by this gem
8
+ # apart from resources created by other Sawyer-based gems
9
+ # loaded in the same process.
10
+ class SawyerAgent < Sawyer::Agent
11
+ end
12
+
13
+ # Strict attribute reads for resources created by this gem
14
+ #
15
+ # Sawyer::Resource#method_missing silently answers reads of absent
16
+ # attributes with nil. That turns typos and renamed API fields
17
+ # (e.g. `time_zone` vs `default_timezone`) into silent data bugs.
18
+ # For resources created by a {Booqable::SawyerAgent}, this module raises
19
+ # {Booqable::MissingAttribute} instead — both for plain reads
20
+ # (`resource.foo`) and predicate reads (`resource.foo?`).
21
+ #
22
+ # Attributes that ARE present in the payload with a null value still
23
+ # return nil — only reads of keys that are absent from the payload raise.
24
+ #
25
+ # Everything else about Sawyer::Resource is unchanged: attribute writes
26
+ # (`resource.foo = 1`) still define new attributes, hash-style access
27
+ # (`resource[:foo]`) stays a lenient probe returning nil for absent keys,
28
+ # and `to_h`/`to_attrs`, `key?`, `dig`, `fetch`, enumeration, marshaling,
29
+ # and respond_to? semantics all behave as before. Resources created by
30
+ # other gems' Sawyer agents are unaffected.
31
+ module StrictAttributes
32
+ # Matches plain attribute reads (`foo`) and predicate reads (`foo?`).
33
+ # Setters (`foo=`) stay permitted since Sawyer allows adding attributes.
34
+ ATTRIBUTE_READ_PATTERN = /\A([a-z0-9_]+)(\?)?\z/i
35
+
36
+ # Raise {Booqable::MissingAttribute} for reads of absent attributes
37
+ # on strict resources; defer to Sawyer's behavior for everything else.
38
+ def method_missing(method, *)
39
+ attr_name = booqable_missing_attribute_read(method)
40
+ raise Booqable::MissingAttribute.new(attr_name, self) if attr_name
41
+
42
+ super
43
+ end
44
+
45
+ private
46
+
47
+ # Returns the attribute name when +method+ is a read of an attribute
48
+ # that is absent from the payload of a strict resource, nil otherwise
49
+ #
50
+ # @param method [Symbol] the method name passed to #method_missing
51
+ # @return [Symbol, nil]
52
+ def booqable_missing_attribute_read(method)
53
+ # _agent/_fields are Sawyer::Resource's public attr_readers — the bare `agent`/`fields`
54
+ # spellings are SPECIAL_METHODS resolved inside method_missing, so calling those from
55
+ # this hook (which method_missing invokes) would recurse infinitely.
56
+ return nil unless _agent.is_a?(Booqable::SawyerAgent)
57
+
58
+ match = ATTRIBUTE_READ_PATTERN.match(method.to_s)
59
+ return nil unless match
60
+
61
+ attr_name = match[1].to_sym
62
+ return nil if _fields.include?(attr_name)
63
+ return nil if match[2].nil? && Sawyer::Resource::SPECIAL_METHODS.include?(match[1])
64
+
65
+ attr_name
66
+ end
67
+ end
68
+ end
69
+
70
+ Sawyer::Resource.prepend(Booqable::StrictAttributes)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Booqable
4
- VERSION = "1.2.0"
4
+ VERSION = "2.0.0"
5
5
  end
data/lib/booqable.rb CHANGED
@@ -6,11 +6,13 @@ require "faraday/retry"
6
6
  require "json"
7
7
  require "sawyer"
8
8
  require "sawyer/link_parsers/simple"
9
+ require "cgi"
9
10
 
10
11
  # Internal
11
12
  require_relative "booqable/version"
12
13
  require_relative "booqable/rate_limit"
13
14
  require_relative "booqable/error"
15
+ require_relative "booqable/strict_attributes"
14
16
  require_relative "booqable/oauth_client"
15
17
  require_relative "booqable/middleware/base"
16
18
  require_relative "booqable/middleware/raise_error"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: booqable
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hrvoje Šimić
@@ -86,6 +86,9 @@ dependencies:
86
86
  - - "~>"
87
87
  - !ruby/object:Gem::Version
88
88
  version: '2.0'
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: 2.0.22
89
92
  type: :runtime
90
93
  prerelease: false
91
94
  version_requirements: !ruby/object:Gem::Requirement
@@ -93,6 +96,9 @@ dependencies:
93
96
  - - "~>"
94
97
  - !ruby/object:Gem::Version
95
98
  version: '2.0'
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: 2.0.22
96
102
  - !ruby/object:Gem::Dependency
97
103
  name: jwt
98
104
  requirement: !ruby/object:Gem::Requirement
@@ -107,6 +113,20 @@ dependencies:
107
113
  - - "~>"
108
114
  - !ruby/object:Gem::Version
109
115
  version: '3.1'
116
+ - !ruby/object:Gem::Dependency
117
+ name: cgi
118
+ requirement: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - "~>"
121
+ - !ruby/object:Gem::Version
122
+ version: '0.5'
123
+ type: :runtime
124
+ prerelease: false
125
+ version_requirements: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: '0.5'
110
130
  description: Ruby toolkit for the Booqable API. Provides a simple interface to interact
111
131
  with all Booqable API endpoints including orders, customers, products, and more.
112
132
  email:
@@ -141,6 +161,7 @@ files:
141
161
  - lib/booqable/resource_proxy.rb
142
162
  - lib/booqable/resources.json
143
163
  - lib/booqable/resources.rb
164
+ - lib/booqable/strict_attributes.rb
144
165
  - lib/booqable/version.rb
145
166
  - sig/booqable.rbs
146
167
  homepage: https://github.com/booqable/booqable.rb
@@ -166,7 +187,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
166
187
  - !ruby/object:Gem::Version
167
188
  version: '0'
168
189
  requirements: []
169
- rubygems_version: 4.0.10
190
+ rubygems_version: 3.6.9
170
191
  specification_version: 4
171
192
  summary: Official Booqable API client for Ruby.
172
193
  test_files: []