jbr 3.1.0 → 3.3.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 +22 -0
- data/README.md +26 -1
- data/lib/graphql/client.rb +2 -0
- data/lib/jbr/oauth.rb +8 -15
- data/lib/jbr/refused.rb +5 -0
- data/lib/jbr/throttle.rb +42 -0
- data/lib/jbr/token.rb +35 -0
- data/lib/jbr/version.rb +1 -1
- data/lib/jbr.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 074fba6db0300fd967935b1996f1791016e216aa49ef829e771f56340400af1f
|
|
4
|
+
data.tar.gz: c8433c006174ab91d76db6a331268c325a90ff4f049f179c5adf46acd9716487
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1d3e116c317b973a9600af44e3fd02466e360321f08b4e1c696c2e214854b91b8318c45a8ffdb7c50f6f880bf07e6388bf060d33f54ad58b60b2ead27aab535d
|
|
7
|
+
data.tar.gz: 28ee328df69a90a166cfaa4ed6ee0b22fad572c7bca8defb501afba0b3054c652b17741df883f4be32b0bd33ca7253a4a7e5872564b2f636f02d459323a7693c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
## [3.3.0] - 2026-08-13
|
|
2
|
+
|
|
3
|
+
- [Fix] Credentials are given up only when Jobber says the grant itself is no good. Any
|
|
4
|
+
refusal at all used to set `invalid_at` -- a 500, a rate limit, an unreadable body -- so
|
|
5
|
+
a moment of trouble at Jobber's end read as a dead token, and an app acting on that could
|
|
6
|
+
revoke one that still worked. Only an `invalid_grant` Jobber names counts now; everything
|
|
7
|
+
else raises `Jbr::Error` for the caller to retry, which is what trouble deserves
|
|
8
|
+
- [New] `Jbr::Refused`, a `Jbr::Error` for the grant being no good rather than for the
|
|
9
|
+
answer failing to arrive. Rescue it to tell the two apart
|
|
10
|
+
- [Change] The token endpoint moved to `Jbr::Token`. `Jbr::OAuth.post` still answers it
|
|
11
|
+
unchanged, and so do `Jbr::OAuth.client_id` and `Jbr::OAuth.client_secret`
|
|
12
|
+
|
|
13
|
+
## [3.2.0] - 2026-08-13
|
|
14
|
+
|
|
15
|
+
- [New] Wait rather than be refused. Jobber holds an app to two limits at once -- 2,500
|
|
16
|
+
requests every five minutes, and a bucket of query cost that drains as it is asked --
|
|
17
|
+
and a walk of many pages could reach either. Every request now spaces itself 0.12s from
|
|
18
|
+
the one before, which is the count spread evenly over the window, and reads the bucket
|
|
19
|
+
Jobber reports beside the data to wait longer where the next page cannot be paid for.
|
|
20
|
+
A request that follows no other waits for nothing, so a single lookup is as quick as it
|
|
21
|
+
was: only a walk long enough to be a problem is slowed, and only as much as it must be
|
|
22
|
+
|
|
1
23
|
## [3.1.0] - 2026-08-13
|
|
2
24
|
|
|
3
25
|
- [Fix] An answer Jobber left empty now reads as no answer rather than as an empty string.
|
data/README.md
CHANGED
|
@@ -41,6 +41,16 @@ Revoke credentials:
|
|
|
41
41
|
oauth.delete
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
Credentials go bad only when Jobber says so. A refused refresh — the `invalid_grant` Jobber
|
|
45
|
+
names — sets `invalid_at` and answers queries with nothing. Anything else that goes wrong,
|
|
46
|
+
including a 500 or a rate limit, raises `Jbr::Error` instead, because a token that may still
|
|
47
|
+
work is worth more than a tidy failure:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
oauth.invalid_at # => 2026-08-13 11:02:41, or nil while the credentials are good
|
|
51
|
+
oauth.query '{ ok }' # => {} once they are refused, raises Jbr::Error where Jobber had trouble
|
|
52
|
+
```
|
|
53
|
+
|
|
44
54
|
### Requests
|
|
45
55
|
|
|
46
56
|
Create a Jobber request, finding or creating a Client with a matching phone number:
|
|
@@ -76,7 +86,8 @@ job.completed_at # => 2026-05-18 11:36:13
|
|
|
76
86
|
|
|
77
87
|
Or walk the account's jobs, oldest first. Jobber is asked for a page at a time, and only
|
|
78
88
|
once the page before it runs out, so `first` costs one request where `to_a` costs as many
|
|
79
|
-
as the account has pages:
|
|
89
|
+
as the account has pages. A walk paces itself, so a long one is never refused: see
|
|
90
|
+
[Rate limits](#rate-limits).
|
|
80
91
|
|
|
81
92
|
```ruby
|
|
82
93
|
jobs = oauth.jobs # => an Enumerable of every job, nothing fetched yet
|
|
@@ -154,6 +165,20 @@ visit.property.client.name # => whoever the place sits on the file of
|
|
|
154
165
|
Ask for nothing and nothing arrives: `oauth.visits.first.client.name` is nil where the
|
|
155
166
|
query never named a client.
|
|
156
167
|
|
|
168
|
+
### Rate limits
|
|
169
|
+
|
|
170
|
+
Jobber holds an app to two limits at once: 2,500 requests every five minutes, and a bucket
|
|
171
|
+
of query cost that drains as it is asked and refills at a rate it reports. Nothing has to be
|
|
172
|
+
done about either — every request waits for itself:
|
|
173
|
+
|
|
174
|
+
- It spaces itself 0.12 seconds from the request before, which is 2,500 spread evenly over
|
|
175
|
+
five minutes.
|
|
176
|
+
- It reads `extensions.cost` off each answer, and where the bucket can no longer pay for a
|
|
177
|
+
page like the last one, it waits for the shortfall to refill at Jobber's own restore rate.
|
|
178
|
+
|
|
179
|
+
A request that follows no other waits for nothing, so a single `find` is as quick as it ever
|
|
180
|
+
was. Only a walk long enough to be a problem is slowed, and only by as much as it must be.
|
|
181
|
+
|
|
157
182
|
### Events
|
|
158
183
|
|
|
159
184
|
Parse the payload of a Jobber event webhook:
|
data/lib/graphql/client.rb
CHANGED
|
@@ -16,6 +16,7 @@ module GraphQL
|
|
|
16
16
|
|
|
17
17
|
# @param query [String] the GraphQL query string.
|
|
18
18
|
# @param variables [Hash] the variables to interpolate into the query.
|
|
19
|
+
# @yield [Hash] the `extensions` the endpoint answered beside the data, where it did.
|
|
19
20
|
# @return [Hash] the `data` portion of the GraphQL response.
|
|
20
21
|
def query(query, variables: {})
|
|
21
22
|
response = Net::HTTP.post @endpoint, { query:, variables: }.to_json, request_headers
|
|
@@ -24,6 +25,7 @@ module GraphQL
|
|
|
24
25
|
body = JSON.parse(response.body)
|
|
25
26
|
errors = body['errors']
|
|
26
27
|
raise Error, errors.map { |error| error['message'] }.join('; ') if errors.present?
|
|
28
|
+
yield body['extensions'] if block_given?
|
|
27
29
|
body.fetch('data')
|
|
28
30
|
end
|
|
29
31
|
|
data/lib/jbr/oauth.rb
CHANGED
|
@@ -34,12 +34,11 @@ module Jbr
|
|
|
34
34
|
def requests = Request.new oauth: self
|
|
35
35
|
def visits = Visits.new oauth: self
|
|
36
36
|
|
|
37
|
-
# Run a statement,
|
|
38
|
-
# @param statement [String] the query or mutation to run.
|
|
39
|
-
# @param variables [Hash] the variables it interpolates.
|
|
37
|
+
# Run a statement, waiting for what Jobber will still answer and refreshing a stale token.
|
|
40
38
|
# @return [Hash] the data Jobber answered, or empty when the credentials are dead.
|
|
41
39
|
def query(statement, variables: {})
|
|
42
|
-
|
|
40
|
+
throttle.wait
|
|
41
|
+
client.query(statement, variables: variables) { |extensions| throttle.read extensions }
|
|
43
42
|
rescue GraphQL::Unauthorized
|
|
44
43
|
refresh ? retry : {}
|
|
45
44
|
end
|
|
@@ -65,27 +64,21 @@ module Jbr
|
|
|
65
64
|
# @return [String, nil] The client secret to interact with the API.
|
|
66
65
|
def self.client_secret = ENV['JOBBER_CLIENT_SECRET']
|
|
67
66
|
|
|
68
|
-
# Exchange a code or a refresh token for credentials.
|
|
69
|
-
# reaches it through self.class, which a private class method forbids.
|
|
67
|
+
# Exchange a code or a refresh token for credentials.
|
|
70
68
|
def self.post(params = {})
|
|
71
|
-
|
|
72
|
-
response = Net::HTTP.post_form uri,
|
|
73
|
-
params.merge(client_id: client_id, client_secret: client_secret)
|
|
74
|
-
raise Error, response.body unless response.is_a? Net::HTTPSuccess
|
|
75
|
-
output = JSON.parse(response.body)
|
|
76
|
-
{ access_token: output['access_token'], refresh_token: output['refresh_token'],
|
|
77
|
-
expires_at: (Time.now + output.fetch('expires_in', 3600).to_i),
|
|
78
|
-
}
|
|
69
|
+
Token.post params.merge(client_id: client_id, client_secret: client_secret)
|
|
79
70
|
end
|
|
80
71
|
|
|
81
72
|
private
|
|
82
73
|
|
|
74
|
+
def throttle = @throttle ||= Throttle.new
|
|
75
|
+
|
|
83
76
|
def refresh
|
|
84
77
|
output = self.class.post refresh_token: @refresh_token, grant_type: 'refresh_token'
|
|
85
78
|
@access_token = output[:access_token]
|
|
86
79
|
@refresh_token = output[:refresh_token]
|
|
87
80
|
@expires_at = output[:expires_at]
|
|
88
|
-
rescue
|
|
81
|
+
rescue Refused
|
|
89
82
|
@invalid_at = Time.now
|
|
90
83
|
false
|
|
91
84
|
end
|
data/lib/jbr/refused.rb
ADDED
data/lib/jbr/throttle.rb
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# How much Jobber will still answer, and how long to wait before asking again. Jobber holds
|
|
3
|
+
# an app to two limits at once, and this keeps both: a count of requests over a window, and
|
|
4
|
+
# a bucket of query cost that drains as it is asked and refills at a rate it reports.
|
|
5
|
+
class Throttle
|
|
6
|
+
# Jobber answers 2,500 requests every 5 minutes, which is one every 0.12 seconds. Spacing
|
|
7
|
+
# them is what keeps a walk of many pages under the count, whatever each page costs.
|
|
8
|
+
SPACING = 300.0 / 2_500
|
|
9
|
+
|
|
10
|
+
# @return [Float] the seconds waited, which is zero where nothing was owed.
|
|
11
|
+
def wait
|
|
12
|
+
owed = [ spacing_owed, restore_owed ].max
|
|
13
|
+
sleep owed if owed.positive?
|
|
14
|
+
@asked_at = Time.now
|
|
15
|
+
owed
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Takes in what the answer said it had left. Jobber reports the bucket beside the data,
|
|
19
|
+
# so what the next caller may ask for is known before they ask for it.
|
|
20
|
+
# @param extensions [Hash, nil] the +extensions+ Jobber answered beside the data.
|
|
21
|
+
def read(extensions)
|
|
22
|
+
cost = extensions.to_h['cost'].to_h
|
|
23
|
+
status = cost['throttleStatus'].to_h
|
|
24
|
+
@cost = cost['actualQueryCost'].to_f
|
|
25
|
+
@available = status['currentlyAvailable'].to_f
|
|
26
|
+
@restore_rate = status['restoreRate'].to_f
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Nothing is owed to the first caller: a request that follows no other is not too soon.
|
|
32
|
+
def spacing_owed = (@asked_at ? [ SPACING - (Time.now - @asked_at), 0.0 ].max : 0.0)
|
|
33
|
+
|
|
34
|
+
# What the last answer cost is what the next one is taken to cost, since a walk asks the
|
|
35
|
+
# same query of every page. Where the bucket cannot pay for it, wait for it to refill.
|
|
36
|
+
def restore_owed
|
|
37
|
+
return 0.0 unless @restore_rate.to_f.positive? && @available.to_f < @cost.to_f
|
|
38
|
+
|
|
39
|
+
(@cost - @available) / @restore_rate
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/jbr/token.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# The endpoint that trades an authorization code or a refresh token for credentials.
|
|
3
|
+
class Token
|
|
4
|
+
# Where a grant is exchanged.
|
|
5
|
+
URL = 'https://api.getjobber.com/api/oauth/token'
|
|
6
|
+
|
|
7
|
+
# What Jobber calls a grant that is no good, in the OAuth 2 word for it.
|
|
8
|
+
REFUSAL = 'invalid_grant'
|
|
9
|
+
|
|
10
|
+
# Trade a grant for credentials.
|
|
11
|
+
# @param params [Hash] the grant, and the app making the exchange.
|
|
12
|
+
# @raise [Refused] where Jobber says the grant itself is no good.
|
|
13
|
+
# @raise [Error] where Jobber could not answer about it.
|
|
14
|
+
# @return [Hash] the tokens, and the moment the access one expires.
|
|
15
|
+
def self.post(params = {})
|
|
16
|
+
response = Net::HTTP.post_form URI(URL), params
|
|
17
|
+
raise Refused, response.body if refused? response
|
|
18
|
+
raise Error, response.body unless response.is_a? Net::HTTPSuccess
|
|
19
|
+
|
|
20
|
+
output = JSON.parse response.body
|
|
21
|
+
{ access_token: output['access_token'], refresh_token: output['refresh_token'],
|
|
22
|
+
expires_at: (Time.now + output.fetch('expires_in', 3600).to_i),
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.refused?(response)
|
|
27
|
+
return false if response.is_a? Net::HTTPSuccess
|
|
28
|
+
|
|
29
|
+
JSON.parse(response.body)['error'] == REFUSAL
|
|
30
|
+
rescue JSON::ParserError
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
private_class_method :refused?
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/jbr/version.rb
CHANGED
data/lib/jbr.rb
CHANGED
|
@@ -15,12 +15,15 @@ require 'jbr/mock'
|
|
|
15
15
|
|
|
16
16
|
require 'jbr/url'
|
|
17
17
|
require 'jbr/error'
|
|
18
|
+
require 'jbr/refused'
|
|
19
|
+
require 'jbr/token'
|
|
18
20
|
# Phone before Cliental, and Cliental before the records that include it: what each asks
|
|
19
21
|
# Jobber for about a client is built as they load.
|
|
20
22
|
require 'jbr/phone'
|
|
21
23
|
require 'jbr/cliental'
|
|
22
24
|
require 'jbr/named'
|
|
23
25
|
require 'jbr/resource'
|
|
26
|
+
require 'jbr/throttle'
|
|
24
27
|
require 'jbr/request'
|
|
25
28
|
require 'jbr/oauth'
|
|
26
29
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jbr
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Claudio Baccigalupo
|
|
@@ -137,8 +137,11 @@ files:
|
|
|
137
137
|
- lib/jbr/properted.rb
|
|
138
138
|
- lib/jbr/property.rb
|
|
139
139
|
- lib/jbr/quote.rb
|
|
140
|
+
- lib/jbr/refused.rb
|
|
140
141
|
- lib/jbr/request.rb
|
|
141
142
|
- lib/jbr/resource.rb
|
|
143
|
+
- lib/jbr/throttle.rb
|
|
144
|
+
- lib/jbr/token.rb
|
|
142
145
|
- lib/jbr/url.rb
|
|
143
146
|
- lib/jbr/version.rb
|
|
144
147
|
- lib/jbr/visit.rb
|