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 +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +27 -0
- data/lib/booqable/client.rb +42 -0
- data/lib/booqable/error.rb +44 -0
- data/lib/booqable/http.rb +8 -6
- data/lib/booqable/resource_parser.rb +4 -3
- data/lib/booqable/strict_attributes.rb +70 -0
- data/lib/booqable/version.rb +1 -1
- data/lib/booqable.rb +2 -0
- metadata +23 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ee065493e3d48d59edfb13552c3dd8dbce2d800ecfb9d3c8230f6f3c52345be0
|
|
4
|
+
data.tar.gz: a9fd9934fd93f8504ca1a58e2cab82e09ab1f4f9f2da9fb4a30033e3f84bfe16
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/booqable/client.rb
CHANGED
|
@@ -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
|
data/lib/booqable/error.rb
CHANGED
|
@@ -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
|
|
247
|
-
# serializer, and optional logging. Sawyer handles the
|
|
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 [
|
|
252
|
+
# @return [Booqable::SawyerAgent] HTTP agent instance
|
|
251
253
|
# @api private
|
|
252
254
|
def agent
|
|
253
|
-
@agent ||=
|
|
254
|
-
|
|
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 [
|
|
75
|
+
# @return [Booqable::SawyerAgent]
|
|
75
76
|
def sawyer_agent
|
|
76
|
-
@sawyer_agent ||=
|
|
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)
|
data/lib/booqable/version.rb
CHANGED
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:
|
|
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:
|
|
190
|
+
rubygems_version: 3.6.9
|
|
170
191
|
specification_version: 4
|
|
171
192
|
summary: Official Booqable API client for Ruby.
|
|
172
193
|
test_files: []
|