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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f70cfb9bf360eac2836271beef56dc7b2da9c5f2355b8fb4ac51c39c3573f2e8
4
- data.tar.gz: cd50eee427d9a2b985a8eb023b1b1afe99a784574d87c52a019fff90e399da47
3
+ metadata.gz: d1028c7784920b94426d3c9d52a29232cdb4b8426baf5abe11cbcfe64d9f854f
4
+ data.tar.gz: d27d31e838ea1fd1c2c3035abf3e21635955039febc8a481bb514a8bd8e9fb9d
5
5
  SHA512:
6
- metadata.gz: 2ddb2eb5dd395b8840ce9b23c55537b6161248002aa131c2759bebbf0dde6bcdfa1c729eadbad3483aead96a232aa258d77a3afed44351f861ac0cffc4ab5729
7
- data.tar.gz: e95678d5a04baa3a9811b261682a6cbf71bc588ca8eb3eb78f9f390b53e0928c42798129705a57e71f4cb6ab46905a3dc520e000219d93dfbf2ad33dfb597157
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 nothing but the standard library.
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:
@@ -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('; ') unless errors.empty?
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?(address)),
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?(email)),
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, refreshing the access token once if Jobber says it expired.
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
- client.query statement, variables: variables
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. Public because #refresh
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] ] }.compact
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] ] }).compact
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
- def time(key) = (Time.iso8601 @node[key] if @node[key])
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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # A Ruby client for the Jobber API.
2
2
  module Jbr
3
3
  # The version of this gem.
4
- VERSION = '3.0.0'
4
+ VERSION = '3.2.0'
5
5
  end
data/lib/jbr/visit.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  module Jbr
2
2
  # One stop at a property: when the work on a job is scheduled to happen.
3
3
  class Visit < Resource
4
- include Cliental, Properted
4
+ include Cliental, Named, Properted
5
5
 
6
6
  # @return [String, nil] what the visit is called.
7
7
  def title = @node['title']
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.0.0
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