booqable 1.0.0 → 1.2.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 -2
- data/README.md +102 -18
- data/lib/booqable/auth.rb +2 -1
- data/lib/booqable/client.rb +17 -0
- data/lib/booqable/configurable.rb +2 -0
- data/lib/booqable/default.rb +11 -0
- data/lib/booqable/error.rb +51 -4
- data/lib/booqable/middleware/auth/oauth.rb +21 -4
- data/lib/booqable/resource_parser.rb +81 -0
- data/lib/booqable/resource_proxy.rb +7 -0
- data/lib/booqable/version.rb +1 -1
- data/lib/booqable.rb +1 -0
- metadata +5 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ce7d8b0be4851e4e78229078331a2f73cbe7cb7702c1555df9dec0903be28d0e
|
|
4
|
+
data.tar.gz: c79ed318eb3a38534291ef5e4abeb6a5749c7a2684bcd1befbf43d6f7f1d0e8a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9d6dead61b0b4bfd8374c9b51aec7dccc724988b5417f9585018ee549840ee207756642b38aedbfcf4fdb4e313f9764fdc64b1d1d6e7593c057edb20d6095afc
|
|
7
|
+
data.tar.gz: c5e34b3a736ab3bdca397dfd1245d07b8dd8e246e32d4d0f24e097c41f52d47307c645a547592e60b90dc27f98e2e817193a1059f2696c0a726316c0693c1f88
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
|
-
## [
|
|
1
|
+
## [1.2.0] - 2026-05-27
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
- Add optional `around_refresh_token` configuration. When provided, the OAuth
|
|
4
|
+
middleware yields the read + expiry-check + refresh sequence to the callable
|
|
5
|
+
so host applications can serialize concurrent token refreshes (e.g. with a
|
|
6
|
+
database transaction and advisory lock). The gem keeps no lock dependency.
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
## [1.1.0] - 2026-03-11
|
|
10
|
+
|
|
11
|
+
- Add `parse_resource` method (aliased as `deserialize_resource`) for parsing
|
|
12
|
+
JSON:API payloads into Sawyer::Resource objects with dot-notation access.
|
|
13
|
+
Useful for parsing webhook payloads or raw API responses.
|
|
14
|
+
- Add Booqable::RefreshTokenRevoked and Booqable::InvalidGrant error types for
|
|
15
|
+
invalid grant OAuth response scenarios
|
|
16
|
+
- Add "all" as an alias for "list" method on all resources.
|
|
17
|
+
You can now use `Booqable.orders.all` as an alternative to `Booqable.orders.list`.
|
|
18
|
+
|
|
19
|
+
## [1.0.0] - 2025-10-23
|
|
4
20
|
|
|
5
21
|
- Initial release
|
data/README.md
CHANGED
|
@@ -5,6 +5,7 @@ Ruby toolkit for the [Booqable API](https://developers.booqable.com/).
|
|
|
5
5
|
[Booqable](https://booqable.com) is a rental management platform that helps businesses manage their rental inventory, customers, and orders. This gem provides a Ruby interface to interact with all Booqable API endpoints.
|
|
6
6
|
|
|
7
7
|
## Table of Contents
|
|
8
|
+
|
|
8
9
|
- [Installation](#installation)
|
|
9
10
|
- [Making requests](#making-requests)
|
|
10
11
|
- [Authentication](#authentication)
|
|
@@ -12,6 +13,7 @@ Ruby toolkit for the [Booqable API](https://developers.booqable.com/).
|
|
|
12
13
|
- [Pagination](#pagination)
|
|
13
14
|
- [Rate limiting](#rate-limiting)
|
|
14
15
|
- [Resources](#resources)
|
|
16
|
+
- [Parsing JSON:API payloads](#parsing-jsonapi-payloads)
|
|
15
17
|
- [Advanced usage](#advanced-usage)
|
|
16
18
|
- [Development](#development)
|
|
17
19
|
|
|
@@ -89,11 +91,11 @@ client = Booqable::Client.new(
|
|
|
89
91
|
client_id: 'your_oauth_client_id',
|
|
90
92
|
client_secret: 'your_oauth_client_secret',
|
|
91
93
|
company_id: 'your_company_id',
|
|
92
|
-
read_token: -> {
|
|
94
|
+
read_token: -> {
|
|
93
95
|
# Return stored token hash
|
|
94
96
|
JSON.parse(File.read('token.json'))
|
|
95
97
|
},
|
|
96
|
-
write_token: ->(token) {
|
|
98
|
+
write_token: ->(token) {
|
|
97
99
|
# Store token hash
|
|
98
100
|
File.write('token.json', token.to_json)
|
|
99
101
|
}
|
|
@@ -103,6 +105,30 @@ client = Booqable::Client.new(
|
|
|
103
105
|
client.authenticate_with_code(params[:code])
|
|
104
106
|
```
|
|
105
107
|
|
|
108
|
+
#### Serializing concurrent token refreshes
|
|
109
|
+
|
|
110
|
+
When multiple processes share the same OAuth token (e.g. the same installation
|
|
111
|
+
serving concurrent requests), pass an `around_refresh_token` callable to
|
|
112
|
+
serialize the read + expiry-check + refresh sequence. The middleware yields
|
|
113
|
+
to the callable once per request; the host application decides how to lock.
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
Booqable::Client.new(
|
|
117
|
+
# ...other oauth options...
|
|
118
|
+
around_refresh_token: ->(&block) {
|
|
119
|
+
AppInstallation.transaction do
|
|
120
|
+
installation.with_advisory_lock!("app_installation:#{installation.id}", transaction: true) do
|
|
121
|
+
installation.reload
|
|
122
|
+
block.call
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The gem itself has no advisory-lock dependency — `around_refresh_token` is
|
|
130
|
+
just a callable that takes a block.
|
|
131
|
+
|
|
106
132
|
### Single-Use Token Authentication
|
|
107
133
|
|
|
108
134
|
For server-to-server communication requiring enhanced security:
|
|
@@ -159,7 +185,7 @@ client = Booqable::Client.new(
|
|
|
159
185
|
|
|
160
186
|
## Pagination
|
|
161
187
|
|
|
162
|
-
|
|
188
|
+
Booqable provides several ways to handle paginated responses:
|
|
163
189
|
|
|
164
190
|
### Manual pagination
|
|
165
191
|
|
|
@@ -222,6 +248,13 @@ orders = Booqable.orders.list(
|
|
|
222
248
|
sort: '-created_at'
|
|
223
249
|
)
|
|
224
250
|
|
|
251
|
+
# `all` is an alias for `list`
|
|
252
|
+
orders = Booqable.orders.all(
|
|
253
|
+
include: 'customer,items',
|
|
254
|
+
filter: { status: 'reserved' },
|
|
255
|
+
sort: '-created_at'
|
|
256
|
+
)
|
|
257
|
+
|
|
225
258
|
# Find specific order
|
|
226
259
|
order = Booqable.orders.find('order_id', include: 'customer,items')
|
|
227
260
|
order.items.count
|
|
@@ -236,7 +269,7 @@ new_order = Booqable.orders.create(
|
|
|
236
269
|
new_order.status # => 'draft'
|
|
237
270
|
|
|
238
271
|
# Update order
|
|
239
|
-
updated_order = Booqable.orders.update('order_id',
|
|
272
|
+
updated_order = Booqable.orders.update('order_id', stops_at: '2024-01-03T00:00:00Z')
|
|
240
273
|
updated_order.status # => 'reserved'
|
|
241
274
|
|
|
242
275
|
# Delete order
|
|
@@ -296,20 +329,83 @@ deleted_product.id # => 'product_id'
|
|
|
296
329
|
Booqable provides access to all Booqable API resources:
|
|
297
330
|
|
|
298
331
|
**Core Resources:**
|
|
332
|
+
|
|
299
333
|
- `orders`, `customers`, `products`, `items`
|
|
300
334
|
- `employees`, `companies`, `locations`
|
|
301
335
|
- `payments`, `invoices`, `documents`
|
|
302
336
|
|
|
303
337
|
**Inventory Management:**
|
|
338
|
+
|
|
304
339
|
- `inventory_levels`, `stock_items`, `stock_adjustments`
|
|
305
340
|
- `transfers`, `plannings`, `clusters`
|
|
306
341
|
|
|
307
342
|
**Configuration:**
|
|
343
|
+
|
|
308
344
|
- `settings`, `properties`, `tax_rates`
|
|
309
345
|
- `payment_methods`, `email_templates`
|
|
310
346
|
|
|
311
347
|
**And many more...** See the [full resource list](lib/booqable/resources.json) for all available endpoints.
|
|
312
348
|
|
|
349
|
+
## Parsing JSON:API payloads
|
|
350
|
+
|
|
351
|
+
Booqable provides a convenient way to parse JSON:API formatted data (such as webhook payloads or raw API responses) into Ruby objects with dot-notation access:
|
|
352
|
+
|
|
353
|
+
```ruby
|
|
354
|
+
# Parse a webhook payload
|
|
355
|
+
payload = {
|
|
356
|
+
"data" => {
|
|
357
|
+
"id" => "abc-123",
|
|
358
|
+
"type" => "customers",
|
|
359
|
+
"attributes" => {
|
|
360
|
+
"name" => "John Doe",
|
|
361
|
+
"email" => "john@example.com",
|
|
362
|
+
"created_at" => "2024-01-15T10:30:00Z"
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
customer = Booqable.parse_resource(payload)
|
|
368
|
+
customer.id # => "abc-123"
|
|
369
|
+
customer.name # => "John Doe"
|
|
370
|
+
customer.email # => "john@example.com"
|
|
371
|
+
customer.created_at # => 2024-01-15 10:30:00 UTC (Time object)
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### With nested relationships
|
|
375
|
+
|
|
376
|
+
```ruby
|
|
377
|
+
payload = {
|
|
378
|
+
"data" => {
|
|
379
|
+
"id" => "order-123",
|
|
380
|
+
"type" => "orders",
|
|
381
|
+
"attributes" => { "status" => "reserved" },
|
|
382
|
+
"relationships" => {
|
|
383
|
+
"customer" => { "data" => { "id" => "customer-456", "type" => "customers" } }
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
"included" => [
|
|
387
|
+
{
|
|
388
|
+
"id" => "customer-456",
|
|
389
|
+
"type" => "customers",
|
|
390
|
+
"attributes" => { "name" => "Jane Smith" }
|
|
391
|
+
}
|
|
392
|
+
]
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
order = Booqable.parse_resource(payload)
|
|
396
|
+
order.status # => "reserved"
|
|
397
|
+
order.customer.name # => "Jane Smith"
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Using with a client instance
|
|
401
|
+
|
|
402
|
+
```ruby
|
|
403
|
+
# If you already have a client instance
|
|
404
|
+
customer = client.parse_resource(payload)
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
`deserialize_resource` is available as an alias for `parse_resource`.
|
|
408
|
+
|
|
313
409
|
## Advanced usage
|
|
314
410
|
|
|
315
411
|
### Custom middleware
|
|
@@ -346,18 +442,6 @@ Booqable.configure do |c|
|
|
|
346
442
|
end
|
|
347
443
|
```
|
|
348
444
|
|
|
349
|
-
### Custom serialization
|
|
350
|
-
|
|
351
|
-
```ruby
|
|
352
|
-
# Access raw response data
|
|
353
|
-
response = Booqable.orders.list
|
|
354
|
-
puts response.class # => Sawyer::Resource
|
|
355
|
-
|
|
356
|
-
# Get response metadata
|
|
357
|
-
puts Booqable.last_response.status
|
|
358
|
-
puts Booqable.last_response.headers
|
|
359
|
-
```
|
|
360
|
-
|
|
361
445
|
## Development
|
|
362
446
|
|
|
363
447
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
@@ -376,7 +460,7 @@ bundle exec rspec spec/booqable/client_spec.rb
|
|
|
376
460
|
|
|
377
461
|
### Contributing
|
|
378
462
|
|
|
379
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/booqable/booqable.rb. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/booqable/booqable.rb/blob/
|
|
463
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/booqable/booqable.rb. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/booqable/booqable.rb/blob/main/CODE_OF_CONDUCT.md).
|
|
380
464
|
|
|
381
465
|
## Versioning
|
|
382
466
|
|
|
@@ -396,7 +480,7 @@ The gem is available as open source under the terms of the [MIT License](https:/
|
|
|
396
480
|
|
|
397
481
|
## Code of Conduct
|
|
398
482
|
|
|
399
|
-
Everyone interacting in the Booqable project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/booqable/booqable.rb/blob/
|
|
483
|
+
Everyone interacting in the Booqable project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/booqable/booqable.rb/blob/main/CODE_OF_CONDUCT.md).
|
|
400
484
|
|
|
401
485
|
---
|
|
402
486
|
|
data/lib/booqable/auth.rb
CHANGED
|
@@ -57,7 +57,8 @@ module Booqable
|
|
|
57
57
|
api_endpoint: api_endpoint,
|
|
58
58
|
redirect_uri: redirect_uri,
|
|
59
59
|
read_token: read_token,
|
|
60
|
-
write_token: write_token
|
|
60
|
+
write_token: write_token,
|
|
61
|
+
around_refresh_token: around_refresh_token
|
|
61
62
|
} if oauth_authenticated?
|
|
62
63
|
|
|
63
64
|
builder.use Booqable::Middleware::Auth::ApiKey, {
|
data/lib/booqable/client.rb
CHANGED
|
@@ -56,6 +56,23 @@ module Booqable
|
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# Parse a JSON:API payload into a Sawyer::Resource
|
|
60
|
+
#
|
|
61
|
+
# Converts JSON:API formatted data (from webhooks or API responses) into
|
|
62
|
+
# Ruby objects with dot-notation access for convenient attribute access.
|
|
63
|
+
#
|
|
64
|
+
# @param payload [String, Hash] JSON:API payload (string or parsed hash)
|
|
65
|
+
# @return [Sawyer::Resource, nil] Parsed resource object, or nil for empty input
|
|
66
|
+
#
|
|
67
|
+
# @example
|
|
68
|
+
# customer = client.parse_resource(webhook_payload)
|
|
69
|
+
# customer.name # => "John"
|
|
70
|
+
#
|
|
71
|
+
def parse_resource(payload)
|
|
72
|
+
Booqable::ResourceParser.parse(payload)
|
|
73
|
+
end
|
|
74
|
+
alias_method :deserialize_resource, :parse_resource
|
|
75
|
+
|
|
59
76
|
# String representation of the client with sensitive information masked
|
|
60
77
|
#
|
|
61
78
|
# Overrides the default inspect method to hide sensitive configuration
|
|
@@ -48,6 +48,7 @@ module Booqable
|
|
|
48
48
|
:proxy,
|
|
49
49
|
:read_token,
|
|
50
50
|
:redirect_uri,
|
|
51
|
+
:around_refresh_token,
|
|
51
52
|
:single_use_token,
|
|
52
53
|
:single_use_token_algorithm,
|
|
53
54
|
:single_use_token_company_id,
|
|
@@ -81,6 +82,7 @@ module Booqable
|
|
|
81
82
|
proxy
|
|
82
83
|
read_token
|
|
83
84
|
redirect_uri
|
|
85
|
+
around_refresh_token
|
|
84
86
|
single_use_token
|
|
85
87
|
single_use_token_algorithm
|
|
86
88
|
single_use_token_company_id
|
data/lib/booqable/default.rb
CHANGED
|
@@ -151,6 +151,17 @@ module Booqable
|
|
|
151
151
|
Proc.new { }
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
+
# Default `around_refresh_token` callable
|
|
155
|
+
#
|
|
156
|
+
# When non-nil, the OAuth middleware yields its read+check+refresh
|
|
157
|
+
# sequence to this callable so the host application can serialize
|
|
158
|
+
# concurrent refreshes (e.g. with an advisory lock).
|
|
159
|
+
#
|
|
160
|
+
# @return [Proc, nil]
|
|
161
|
+
def around_refresh_token
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
154
165
|
# Default API key from ENV
|
|
155
166
|
# @return [String, nil] API key for authentication
|
|
156
167
|
def api_key
|
data/lib/booqable/error.rb
CHANGED
|
@@ -42,8 +42,8 @@ module Booqable
|
|
|
42
42
|
# headers = response[:response_headers]
|
|
43
43
|
|
|
44
44
|
if klass = case status
|
|
45
|
-
when 400 then error_for_400(
|
|
46
|
-
when 401 then
|
|
45
|
+
when 400 then error_for_400(response)
|
|
46
|
+
when 401 then error_for_401(response)
|
|
47
47
|
when 402 then error_for_402(body)
|
|
48
48
|
when 403 then Booqable::Forbidden
|
|
49
49
|
when 404 then error_for_404(body)
|
|
@@ -85,8 +85,8 @@ module Booqable
|
|
|
85
85
|
# Return most appropriate error for 400 HTTP status code
|
|
86
86
|
# @private
|
|
87
87
|
# rubocop:disable Metrics/CyclomaticComplexity
|
|
88
|
-
def self.error_for_400(
|
|
89
|
-
case body
|
|
88
|
+
def self.error_for_400(response)
|
|
89
|
+
case response.body
|
|
90
90
|
when /unwrittable_attribute/i
|
|
91
91
|
Booqable::ReadOnlyAttribute
|
|
92
92
|
when /unknown_attribute/i
|
|
@@ -103,12 +103,25 @@ module Booqable
|
|
|
103
103
|
Booqable::InvalidFilter
|
|
104
104
|
when /required filter/i
|
|
105
105
|
Booqable::RequiredFilter
|
|
106
|
+
when /invalid_grant/i
|
|
107
|
+
error_for_invalid_grant(response)
|
|
106
108
|
else
|
|
107
109
|
Booqable::BadRequest
|
|
108
110
|
end
|
|
109
111
|
end
|
|
110
112
|
# rubocop:enable Metrics/CyclomaticComplexity
|
|
111
113
|
|
|
114
|
+
# Return most appropriate error for 401 HTTP status code
|
|
115
|
+
# @private
|
|
116
|
+
def self.error_for_401(response)
|
|
117
|
+
case response.body
|
|
118
|
+
when /token is invalid \(revoked\)/i
|
|
119
|
+
Booqable::TokenRevoked
|
|
120
|
+
else
|
|
121
|
+
Booqable::Unauthorized
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
112
125
|
# Return most appropriate error for 402 HTTP status code
|
|
113
126
|
# @private
|
|
114
127
|
# rubocop:disable Metrics/CyclomaticComplexity
|
|
@@ -163,6 +176,26 @@ module Booqable
|
|
|
163
176
|
end
|
|
164
177
|
end
|
|
165
178
|
|
|
179
|
+
# Return most appropriate error for invalid_grant OAuth error
|
|
180
|
+
#
|
|
181
|
+
# Determines whether the invalid_grant error is due to a revoked refresh token
|
|
182
|
+
# or a different OAuth grant error by examining the grant_type parameter
|
|
183
|
+
# in the request body.
|
|
184
|
+
#
|
|
185
|
+
# @param response [Hash] HTTP response containing the request body
|
|
186
|
+
# @return [Class] RefreshTokenRevoked if grant_type is refresh_token, InvalidGrant otherwise
|
|
187
|
+
# @private
|
|
188
|
+
def self.error_for_invalid_grant(response)
|
|
189
|
+
grant_type = CGI.parse(response.request_body).dig("grant_type", 0)
|
|
190
|
+
|
|
191
|
+
case grant_type
|
|
192
|
+
when /refresh_token/i
|
|
193
|
+
Booqable::RefreshTokenRevoked
|
|
194
|
+
else
|
|
195
|
+
Booqable::InvalidGrant
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
166
199
|
# Array of validation errors
|
|
167
200
|
# @return [Array<Hash>] Error info
|
|
168
201
|
def errors
|
|
@@ -300,6 +333,20 @@ module Booqable
|
|
|
300
333
|
# and body matches 'required filter'
|
|
301
334
|
class RequiredFilter < ClientError; end
|
|
302
335
|
|
|
336
|
+
# Raised when Booqable returns a 401 HTTP status code
|
|
337
|
+
# and body matches 'token is invalid (revoked)'
|
|
338
|
+
class TokenRevoked < ClientError; end
|
|
339
|
+
|
|
340
|
+
# Raised when Booqable returns a 400 HTTP status code
|
|
341
|
+
# and body matches 'invalid_grant' and
|
|
342
|
+
# the grant type is refresh token (OAuth error)
|
|
343
|
+
class RefreshTokenRevoked < TokenRevoked; end
|
|
344
|
+
|
|
345
|
+
# Raised when Booqable returns a 400 HTTP status code
|
|
346
|
+
# and body matches 'invalid_grant' and
|
|
347
|
+
# grant type is not refresh token (OAuth error)
|
|
348
|
+
class InvalidGrant < ClientError; end
|
|
349
|
+
|
|
303
350
|
# Raised when Booqable returns a 401 HTTP status code
|
|
304
351
|
class Unauthorized < ClientError; end
|
|
305
352
|
|
|
@@ -24,6 +24,10 @@ module Booqable
|
|
|
24
24
|
# @option options [String] :api_endpoint API endpoint URL for the OAuth provider
|
|
25
25
|
# @option options [Proc] :read_token Proc to read stored token
|
|
26
26
|
# @option options [Proc] :write_token Proc to store new token
|
|
27
|
+
# @option options [Proc, nil] :around_refresh_token Optional callable
|
|
28
|
+
# invoked with a block around the read+check+refresh sequence. The
|
|
29
|
+
# host application can use it to serialize concurrent refreshes
|
|
30
|
+
# (e.g. wrap the block in a database transaction + advisory lock).
|
|
27
31
|
# @raise [KeyError] If required options are not provided
|
|
28
32
|
def initialize(app, options = {})
|
|
29
33
|
super(app)
|
|
@@ -33,6 +37,7 @@ module Booqable
|
|
|
33
37
|
@api_endpoint = options.fetch(:api_endpoint)
|
|
34
38
|
@read_token = options.fetch(:read_token)
|
|
35
39
|
@write_token = options.fetch(:write_token)
|
|
40
|
+
@around_refresh_token = options[:around_refresh_token]
|
|
36
41
|
|
|
37
42
|
@client = OAuthClient.new(
|
|
38
43
|
client_id: @client_id,
|
|
@@ -50,10 +55,12 @@ module Booqable
|
|
|
50
55
|
# @param env [Faraday::Env] The request environment
|
|
51
56
|
# @return [Faraday::Response] The response from the next middleware
|
|
52
57
|
def call(env)
|
|
53
|
-
|
|
58
|
+
around_refresh_token do
|
|
59
|
+
@token = @client.get_access_token_from_hash(@read_token.call)
|
|
54
60
|
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
if @token.expired? || @token.expires_at.nil?
|
|
62
|
+
@token = refresh_token!
|
|
63
|
+
end
|
|
57
64
|
end
|
|
58
65
|
|
|
59
66
|
env.request_headers["Authorization"] ||= "Bearer #{@token.token}"
|
|
@@ -63,6 +70,16 @@ module Booqable
|
|
|
63
70
|
|
|
64
71
|
private
|
|
65
72
|
|
|
73
|
+
# Yield to the configured around-callback, if any
|
|
74
|
+
#
|
|
75
|
+
# When a host application provides one (e.g. an advisory lock), the
|
|
76
|
+
# read+check+refresh sequence runs inside it so concurrent callers
|
|
77
|
+
# cannot interleave a read with another caller's refresh.
|
|
78
|
+
def around_refresh_token(&block)
|
|
79
|
+
return yield unless @around_refresh_token
|
|
80
|
+
@around_refresh_token.call(&block)
|
|
81
|
+
end
|
|
82
|
+
|
|
66
83
|
# Refresh the expired OAuth token
|
|
67
84
|
#
|
|
68
85
|
# Uses the refresh token to obtain a new access token and stores it
|
|
@@ -78,7 +95,7 @@ module Booqable
|
|
|
78
95
|
|
|
79
96
|
new_token
|
|
80
97
|
rescue OAuth2::Error => e
|
|
81
|
-
response = e.response.response.env
|
|
98
|
+
response = e.response.response.env
|
|
82
99
|
|
|
83
100
|
Booqable::Error.from_response(response)
|
|
84
101
|
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Booqable
|
|
4
|
+
# Parses JSON:API payloads into Sawyer::Resource objects
|
|
5
|
+
#
|
|
6
|
+
# JSON:API formatted data (from webhooks or API responses) is converted
|
|
7
|
+
# into Ruby objects with dot-notation access for convenient attribute access.
|
|
8
|
+
#
|
|
9
|
+
# @example Parse a JSON:API payload
|
|
10
|
+
# payload = '{"data":{"id":"123","type":"customers","attributes":{"name":"John"}}}'
|
|
11
|
+
# customer = Booqable::ResourceParser.parse(payload)
|
|
12
|
+
# customer.id # => "123"
|
|
13
|
+
# customer.name # => "John"
|
|
14
|
+
#
|
|
15
|
+
# @example Parse with nested relationships
|
|
16
|
+
# payload = {
|
|
17
|
+
# "data" => {
|
|
18
|
+
# "id" => "123",
|
|
19
|
+
# "type" => "orders",
|
|
20
|
+
# "attributes" => { "status" => "reserved" },
|
|
21
|
+
# "relationships" => {
|
|
22
|
+
# "customer" => { "data" => { "id" => "456", "type" => "customers" } }
|
|
23
|
+
# }
|
|
24
|
+
# },
|
|
25
|
+
# "included" => [
|
|
26
|
+
# { "id" => "456", "type" => "customers", "attributes" => { "name" => "John" } }
|
|
27
|
+
# ]
|
|
28
|
+
# }
|
|
29
|
+
# order = Booqable::ResourceParser.parse(payload)
|
|
30
|
+
# order.customer.name # => "John"
|
|
31
|
+
#
|
|
32
|
+
class ResourceParser
|
|
33
|
+
# Parse a JSON:API payload into a Sawyer::Resource
|
|
34
|
+
#
|
|
35
|
+
# @param payload [String, Hash] JSON:API payload (string or parsed hash)
|
|
36
|
+
# @return [Sawyer::Resource, nil] Parsed resource object with dot-notation access,
|
|
37
|
+
# or nil for empty/nil input
|
|
38
|
+
def self.parse(payload)
|
|
39
|
+
new(payload).parse
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Initialize a new ResourceParser
|
|
43
|
+
#
|
|
44
|
+
# @param payload [String, Hash] JSON:API payload
|
|
45
|
+
def initialize(payload)
|
|
46
|
+
@payload = payload
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Parse the payload into a Sawyer::Resource
|
|
50
|
+
#
|
|
51
|
+
# @return [Sawyer::Resource, nil] Parsed resource or nil for empty input
|
|
52
|
+
def parse
|
|
53
|
+
return nil if @payload.nil?
|
|
54
|
+
|
|
55
|
+
json_string = @payload.is_a?(String) ? @payload : @payload.to_json
|
|
56
|
+
return nil if json_string.strip.empty?
|
|
57
|
+
|
|
58
|
+
serializer = JsonApiSerializer.any_json
|
|
59
|
+
decoded = serializer.decode(json_string)
|
|
60
|
+
|
|
61
|
+
return nil unless decoded && decoded[:data]
|
|
62
|
+
|
|
63
|
+
Sawyer::Resource.new(sawyer_agent, decoded[:data])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# Create a minimal Sawyer agent for wrapping resources
|
|
69
|
+
#
|
|
70
|
+
# The agent URL is a placeholder - we don't make any HTTP requests.
|
|
71
|
+
# We just need the agent to create Sawyer::Resource objects that
|
|
72
|
+
# provide dot-notation attribute access.
|
|
73
|
+
#
|
|
74
|
+
# @return [Sawyer::Agent]
|
|
75
|
+
def sawyer_agent
|
|
76
|
+
@sawyer_agent ||= Sawyer::Agent.new("https://example.com") do |http|
|
|
77
|
+
http.headers[:content_type] = "application/json"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
data/lib/booqable/version.rb
CHANGED
data/lib/booqable.rb
CHANGED
|
@@ -25,6 +25,7 @@ require_relative "booqable/resources"
|
|
|
25
25
|
require_relative "booqable/auth"
|
|
26
26
|
require_relative "booqable/http"
|
|
27
27
|
require_relative "booqable/client"
|
|
28
|
+
require_relative "booqable/resource_parser"
|
|
28
29
|
|
|
29
30
|
# Main Booqable module providing access to the Booqable API
|
|
30
31
|
#
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: booqable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Hrvoje Šimić
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: faraday
|
|
@@ -138,6 +137,7 @@ files:
|
|
|
138
137
|
- lib/booqable/middleware/raise_error.rb
|
|
139
138
|
- lib/booqable/oauth_client.rb
|
|
140
139
|
- lib/booqable/rate_limit.rb
|
|
140
|
+
- lib/booqable/resource_parser.rb
|
|
141
141
|
- lib/booqable/resource_proxy.rb
|
|
142
142
|
- lib/booqable/resources.json
|
|
143
143
|
- lib/booqable/resources.rb
|
|
@@ -149,10 +149,9 @@ licenses:
|
|
|
149
149
|
metadata:
|
|
150
150
|
homepage_uri: https://github.com/booqable/booqable.rb
|
|
151
151
|
source_code_uri: https://github.com/booqable/booqable.rb
|
|
152
|
-
changelog_uri: https://github.com/booqable/booqable.rb/blob/
|
|
152
|
+
changelog_uri: https://github.com/booqable/booqable.rb/blob/main/CHANGELOG.md
|
|
153
153
|
documentation_uri: https://developers.booqable.com/
|
|
154
154
|
bug_tracker_uri: https://github.com/booqable/booqable.rb/issues
|
|
155
|
-
post_install_message:
|
|
156
155
|
rdoc_options: []
|
|
157
156
|
require_paths:
|
|
158
157
|
- lib
|
|
@@ -167,8 +166,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
167
166
|
- !ruby/object:Gem::Version
|
|
168
167
|
version: '0'
|
|
169
168
|
requirements: []
|
|
170
|
-
rubygems_version:
|
|
171
|
-
signing_key:
|
|
169
|
+
rubygems_version: 4.0.10
|
|
172
170
|
specification_version: 4
|
|
173
171
|
summary: Official Booqable API client for Ruby.
|
|
174
172
|
test_files: []
|