jbr 3.0.0 → 3.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 +28 -0
- data/README.md +22 -4
- data/lib/graphql/client.rb +4 -2
- data/lib/jbr/client.rb +4 -6
- data/lib/jbr/job.rb +1 -5
- data/lib/jbr/named.rb +9 -0
- data/lib/jbr/oauth.rb +6 -6
- data/lib/jbr/property.rb +2 -2
- data/lib/jbr/resource.rb +3 -2
- data/lib/jbr/throttle.rb +42 -0
- data/lib/jbr/version.rb +1 -1
- data/lib/jbr/visit.rb +1 -1
- data/lib/jbr.rb +8 -0
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d1028c7784920b94426d3c9d52a29232cdb4b8426baf5abe11cbcfe64d9f854f
|
|
4
|
+
data.tar.gz: d27d31e838ea1fd1c2c3035abf3e21635955039febc8a481bb514a8bd8e9fb9d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 74d582435245e0aebf5975748e2ba86e5e53f1190a4f4007d1b50e514df47816082f82a8cdf41b8bd9529e63fe25219035005335ed56708b857ba6aabfdbcb4e
|
|
7
|
+
data.tar.gz: 6977198a4d12a410b0c2b8ef2aa0cda829a43ec5fda670ce7d9dbcd13301ac2ef3da55b605d4a6ab7e3017839a115c9f757a6a4ae6d86522f4fa584151cb4f12
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
## [3.2.0] - 2026-08-13
|
|
2
|
+
|
|
3
|
+
- [New] Wait rather than be refused. Jobber holds an app to two limits at once -- 2,500
|
|
4
|
+
requests every five minutes, and a bucket of query cost that drains as it is asked --
|
|
5
|
+
and a walk of many pages could reach either. Every request now spaces itself 0.12s from
|
|
6
|
+
the one before, which is the count spread evenly over the window, and reads the bucket
|
|
7
|
+
Jobber reports beside the data to wait longer where the next page cannot be paid for.
|
|
8
|
+
A request that follows no other waits for nothing, so a single lookup is as quick as it
|
|
9
|
+
was: only a walk long enough to be a problem is slowed, and only as much as it must be
|
|
10
|
+
|
|
11
|
+
## [3.1.0] - 2026-08-13
|
|
12
|
+
|
|
13
|
+
- [Fix] An answer Jobber left empty now reads as no answer rather than as an empty string.
|
|
14
|
+
`client.name` and `job.name` fall through a blank first name or a blank title instead of
|
|
15
|
+
handing one back, so a caller that validates presence is not handed `""` to store. A job
|
|
16
|
+
with no title still answers the ID it is filed under; a client with neither a first name
|
|
17
|
+
nor a company name answers nil
|
|
18
|
+
- [New] A visit answers `name` as a job does -- its title, or the ID Jobber files it under
|
|
19
|
+
where nobody titled it. Both read it from `Jbr::Named`, so a record Jobber lets go
|
|
20
|
+
untitled is named the same way wherever it appears
|
|
21
|
+
- [Fix] A blank address field does not come back from `property.address`, and is not sent
|
|
22
|
+
when a property is opened. Street, city, state and ZIP are absent rather than empty
|
|
23
|
+
- [Fix] A blank timestamp reads as no time. `Time.iso8601` raises on an empty string, so a
|
|
24
|
+
visit or job Jobber dated with one used to take the whole walk down with it
|
|
25
|
+
- [Change] Depend on Active Support, for `blank?`, `present?`, `presence` and
|
|
26
|
+
`compact_blank`. Two of its files are required, not the whole library: telling an empty
|
|
27
|
+
answer from a missing one was being done by hand, and being done inconsistently
|
|
28
|
+
|
|
1
29
|
## [3.0.0] - 2026-08-13
|
|
2
30
|
|
|
3
31
|
- [Breaking change] `oauth.visits` and `oauth.jobs` answer the whole collection rather than
|
data/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Jobber API Ruby client
|
|
2
2
|
|
|
3
|
-
A client for the Jobber GraphQL API. It needs
|
|
3
|
+
A client for the Jobber GraphQL API. It needs the standard library and two files of Active
|
|
4
|
+
Support, to tell a field Jobber answered empty from one it never answered at all.
|
|
4
5
|
|
|
5
6
|
## Available methods
|
|
6
7
|
|
|
@@ -75,7 +76,8 @@ job.completed_at # => 2026-05-18 11:36:13
|
|
|
75
76
|
|
|
76
77
|
Or walk the account's jobs, oldest first. Jobber is asked for a page at a time, and only
|
|
77
78
|
once the page before it runs out, so `first` costs one request where `to_a` costs as many
|
|
78
|
-
as the account has pages:
|
|
79
|
+
as the account has pages. A walk paces itself, so a long one is never refused: see
|
|
80
|
+
[Rate limits](#rate-limits).
|
|
79
81
|
|
|
80
82
|
```ruby
|
|
81
83
|
jobs = oauth.jobs # => an Enumerable of every job, nothing fetched yet
|
|
@@ -83,7 +85,7 @@ oauth.jobs.past # => an Enumerator of the ones dated before now
|
|
|
83
85
|
oauth.jobs.upcoming # => an Enumerator of the ones dated from now on
|
|
84
86
|
|
|
85
87
|
job = jobs.first
|
|
86
|
-
job.name # => 'Furnace tune-up', or the job's ID where nobody titled it. Never nil
|
|
88
|
+
job.name # => 'Furnace tune-up', or the job's ID where nobody titled it. Never nil or empty
|
|
87
89
|
job.title # => 'Furnace tune-up'
|
|
88
90
|
job.instructions # => 'Ring the doorbell twice'
|
|
89
91
|
job.status # => 'requires_invoicing'
|
|
@@ -116,6 +118,7 @@ oauth.visits.past # => an Enumerator of the ones dated before now
|
|
|
116
118
|
|
|
117
119
|
visit = visits.first
|
|
118
120
|
visit.id # => 'Z2lkOi8vS'
|
|
121
|
+
visit.name # => 'Furnace tune-up', or the visit's ID where nobody titled it. Never nil or empty
|
|
119
122
|
visit.title # => 'Furnace tune-up'
|
|
120
123
|
visit.job_id # => 'Z2lkOi8vS'
|
|
121
124
|
visit.starts_at # => 2026-08-09 14:00:00
|
|
@@ -132,7 +135,8 @@ asked for. Chain `includes` the way Active Record does, on visits or on jobs:
|
|
|
132
135
|
```ruby
|
|
133
136
|
visit = oauth.visits.includes(:client, property: :client).upcoming.first
|
|
134
137
|
|
|
135
|
-
visit.client.name # => 'Jane', or the business's name where the client is a business
|
|
138
|
+
visit.client.name # => 'Jane', or the business's name where the client is a business.
|
|
139
|
+
# Never an empty string: a blank first name falls through to the company
|
|
136
140
|
visit.client.first_name # => 'Jane'
|
|
137
141
|
visit.client.last_name # => 'Doe'
|
|
138
142
|
visit.client.company_name # => nil
|
|
@@ -151,6 +155,20 @@ visit.property.client.name # => whoever the place sits on the file of
|
|
|
151
155
|
Ask for nothing and nothing arrives: `oauth.visits.first.client.name` is nil where the
|
|
152
156
|
query never named a client.
|
|
153
157
|
|
|
158
|
+
### Rate limits
|
|
159
|
+
|
|
160
|
+
Jobber holds an app to two limits at once: 2,500 requests every five minutes, and a bucket
|
|
161
|
+
of query cost that drains as it is asked and refills at a rate it reports. Nothing has to be
|
|
162
|
+
done about either — every request waits for itself:
|
|
163
|
+
|
|
164
|
+
- It spaces itself 0.12 seconds from the request before, which is 2,500 spread evenly over
|
|
165
|
+
five minutes.
|
|
166
|
+
- It reads `extensions.cost` off each answer, and where the bucket can no longer pay for a
|
|
167
|
+
page like the last one, it waits for the shortfall to refill at Jobber's own restore rate.
|
|
168
|
+
|
|
169
|
+
A request that follows no other waits for nothing, so a single `find` is as quick as it ever
|
|
170
|
+
was. Only a walk long enough to be a problem is slowed, and only by as much as it must be.
|
|
171
|
+
|
|
154
172
|
### Events
|
|
155
173
|
|
|
156
174
|
Parse the payload of a Jobber event webhook:
|
data/lib/graphql/client.rb
CHANGED
|
@@ -16,14 +16,16 @@ 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
|
|
22
23
|
raise Unauthorized, response.body if response.code == '401'
|
|
23
24
|
raise Error, response.body unless response.is_a? Net::HTTPSuccess
|
|
24
25
|
body = JSON.parse(response.body)
|
|
25
|
-
errors = body['errors']
|
|
26
|
-
raise Error, errors.map { |error| error['message'] }.join('; ')
|
|
26
|
+
errors = body['errors']
|
|
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/client.rb
CHANGED
|
@@ -32,8 +32,8 @@ module Jbr
|
|
|
32
32
|
def company_name = @node['companyName']
|
|
33
33
|
|
|
34
34
|
# Jobber files nobody without one name or the other, so there is always one to call them.
|
|
35
|
-
# @return [String] the person's first name, or the business's name.
|
|
36
|
-
def name = first_name || company_name
|
|
35
|
+
# @return [String, nil] the person's first name, or the business's name. Never empty.
|
|
36
|
+
def name = first_name.presence || company_name.presence
|
|
37
37
|
|
|
38
38
|
# @return [String, nil] the address to write to.
|
|
39
39
|
def email = @node['email']
|
|
@@ -89,12 +89,10 @@ module Jbr
|
|
|
89
89
|
address, email = @create_params[:address], @create_params[:email]
|
|
90
90
|
{ firstName: @create_params[:first_name],
|
|
91
91
|
lastName: @create_params[:last_name],
|
|
92
|
-
properties: ([ { address: Property.address_from(address) } ] if present?
|
|
92
|
+
properties: ([ { address: Property.address_from(address) } ] if address.present?),
|
|
93
93
|
phones: [ { number: @create_params[:phone], primary: true } ],
|
|
94
|
-
emails: ([ { address: email, primary: true } ] if present?
|
|
94
|
+
emails: ([ { address: email, primary: true } ] if email.present?),
|
|
95
95
|
}.compact
|
|
96
96
|
end
|
|
97
|
-
|
|
98
|
-
def present?(value) = !value.nil? && !(value.respond_to?(:empty?) && value.empty?)
|
|
99
97
|
end
|
|
100
98
|
end
|
data/lib/jbr/job.rb
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
module Jbr
|
|
2
2
|
# Work a Jobber user accepted and scheduled.
|
|
3
3
|
class Job < Resource
|
|
4
|
-
include Cliental, Properted
|
|
4
|
+
include Cliental, Named, Properted
|
|
5
5
|
|
|
6
6
|
# @return [String, nil] what the job is called, where whoever opened it named it.
|
|
7
7
|
def title = @node['title']
|
|
8
8
|
|
|
9
|
-
# A job goes untitled often enough, and something has to stand in for it on a list.
|
|
10
|
-
# @return [String] the title, or the ID Jobber files the job under.
|
|
11
|
-
def name = title || id
|
|
12
|
-
|
|
13
9
|
# @return [String, nil] what the work is, in the words whoever opened the job wrote.
|
|
14
10
|
def instructions = @node['instructions']
|
|
15
11
|
|
data/lib/jbr/named.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# Extends a record Jobber lets go untitled: a visit, a job, anything somebody may have
|
|
3
|
+
# opened without ever typing a name for it.
|
|
4
|
+
module Named
|
|
5
|
+
# Untitled happens often enough, and something has to stand in for it on a list.
|
|
6
|
+
# @return [String] the title, or the ID Jobber files the record under. Never empty.
|
|
7
|
+
def name = title.presence || id
|
|
8
|
+
end
|
|
9
|
+
end
|
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,8 +64,7 @@ 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
69
|
uri = URI 'https://api.getjobber.com/api/oauth/token'
|
|
72
70
|
response = Net::HTTP.post_form uri,
|
|
@@ -80,6 +78,8 @@ module Jbr
|
|
|
80
78
|
|
|
81
79
|
private
|
|
82
80
|
|
|
81
|
+
def throttle = @throttle ||= Throttle.new
|
|
82
|
+
|
|
83
83
|
def refresh
|
|
84
84
|
output = self.class.post refresh_token: @refresh_token, grant_type: 'refresh_token'
|
|
85
85
|
@access_token = output[:access_token]
|
data/lib/jbr/property.rb
CHANGED
|
@@ -44,7 +44,7 @@ module Jbr
|
|
|
44
44
|
# @param fields [Hash] any of :street, :city, :state and :zip.
|
|
45
45
|
# @return [Hash] the address, without the fields the caller left out.
|
|
46
46
|
def self.address_from(fields = {})
|
|
47
|
-
FIELDS.to_h { |jobber, ours| [ jobber, fields[ours] ] }.
|
|
47
|
+
FIELDS.to_h { |jobber, ours| [ jobber, fields[ours] ] }.compact_blank
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
# The fields a caller reads, from the address as Jobber holds it.
|
|
@@ -55,7 +55,7 @@ module Jbr
|
|
|
55
55
|
address ||= {}
|
|
56
56
|
coordinates = address['coordinates'] || {}
|
|
57
57
|
FIELDS.to_h { |jobber, ours| [ ours, address[jobber.to_s] ] }.
|
|
58
|
-
merge(COORDINATES.to_h { |ours| [ ours, coordinates[ours.to_s] ] }).
|
|
58
|
+
merge(COORDINATES.to_h { |ours| [ ours, coordinates[ours.to_s] ] }).compact_blank
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
# Reach the property at an address, adding one when none of the client's matches.
|
data/lib/jbr/resource.rb
CHANGED
|
@@ -17,8 +17,9 @@ module Jbr
|
|
|
17
17
|
|
|
18
18
|
private
|
|
19
19
|
|
|
20
|
-
# @return [Time, nil] what Jobber answered under a key, as a time.
|
|
21
|
-
|
|
20
|
+
# @return [Time, nil] what Jobber answered under a key, as a time. An empty answer is no
|
|
21
|
+
# answer: Time.iso8601 raises on one, where nothing at all it simply has none of.
|
|
22
|
+
def time(key) = (Time.iso8601 @node[key] if @node[key].present?)
|
|
22
23
|
|
|
23
24
|
# Every item a paged query answers, one at a time, a page read only once the one before
|
|
24
25
|
# it runs out. The filter is data: handed none, the query narrows nothing.
|
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/version.rb
CHANGED
data/lib/jbr/visit.rb
CHANGED
data/lib/jbr.rb
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
require 'json'
|
|
2
2
|
require 'net/http'
|
|
3
3
|
|
|
4
|
+
# Only the two Active Support files whose methods are used, rather than the whole of it:
|
|
5
|
+
# Jobber answers a field it holds nothing for with an empty string as readily as with null,
|
|
6
|
+
# and a caller who validates presence needs those to arrive as the same nothing.
|
|
7
|
+
require 'active_support/core_ext/object/blank'
|
|
8
|
+
require 'active_support/core_ext/enumerable'
|
|
9
|
+
|
|
4
10
|
require 'graphql/error'
|
|
5
11
|
require 'graphql/unauthorized'
|
|
6
12
|
require 'graphql/client'
|
|
@@ -13,7 +19,9 @@ require 'jbr/error'
|
|
|
13
19
|
# Jobber for about a client is built as they load.
|
|
14
20
|
require 'jbr/phone'
|
|
15
21
|
require 'jbr/cliental'
|
|
22
|
+
require 'jbr/named'
|
|
16
23
|
require 'jbr/resource'
|
|
24
|
+
require 'jbr/throttle'
|
|
17
25
|
require 'jbr/request'
|
|
18
26
|
require 'jbr/oauth'
|
|
19
27
|
|
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.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Claudio Baccigalupo
|
|
@@ -9,6 +9,20 @@ bindir: exe
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activesupport
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
12
26
|
- !ruby/object:Gem::Dependency
|
|
13
27
|
name: minitest
|
|
14
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -117,6 +131,7 @@ files:
|
|
|
117
131
|
- lib/jbr/mock/visit.rb
|
|
118
132
|
- lib/jbr/mock/visits.rb
|
|
119
133
|
- lib/jbr/mocking.rb
|
|
134
|
+
- lib/jbr/named.rb
|
|
120
135
|
- lib/jbr/oauth.rb
|
|
121
136
|
- lib/jbr/phone.rb
|
|
122
137
|
- lib/jbr/properted.rb
|
|
@@ -124,6 +139,7 @@ files:
|
|
|
124
139
|
- lib/jbr/quote.rb
|
|
125
140
|
- lib/jbr/request.rb
|
|
126
141
|
- lib/jbr/resource.rb
|
|
142
|
+
- lib/jbr/throttle.rb
|
|
127
143
|
- lib/jbr/url.rb
|
|
128
144
|
- lib/jbr/version.rb
|
|
129
145
|
- lib/jbr/visit.rb
|