jbr 3.7.2 → 3.9.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 +9 -0
- data/README.md +27 -6
- data/lib/graphql/client.rb +12 -1
- data/lib/graphql/throttled.rb +16 -0
- data/lib/jbr/jobs.rb +4 -25
- data/lib/jbr/listable.rb +73 -0
- data/lib/jbr/mock/jobs.rb +12 -11
- data/lib/jbr/mock/visits.rb +13 -12
- data/lib/jbr/oauth.rb +2 -0
- data/lib/jbr/resource.rb +4 -24
- data/lib/jbr/retriable.rb +20 -0
- data/lib/jbr/version.rb +1 -1
- data/lib/jbr/visits.rb +5 -24
- 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: 815b57ea81f9bda41efb8b7d09a0767224763b895126a3ee56acabab6266b3fc
|
|
4
|
+
data.tar.gz: 127a174761ddb6bc0cc00cb34ffd9e49044152d575f3f5e4f42a030f222023a5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3be4d269a045c6d9e0629a27922697f93c102259eee21762fcef02b0e5257f1b4ebd05e56812faa47e41b68a94ea440624c02621eaa95a4449db42dda94d62f4
|
|
7
|
+
data.tar.gz: d34a6393d787cb20628cf4a18e0c63ffe63bdecaa3cf00e7e84016f8423fca125567549cda5ced525770b07d6354103be476c00d54b21b936e6769a728efe8b8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## [3.8.0] - 2026-08-18
|
|
2
|
+
|
|
3
|
+
- [New] `Jbr::Retriable`, a `Jbr::Error` for a query Jobber refused over what it costs rather
|
|
4
|
+
than over anything about the query. Every refusal read alike before, so an app had nothing to
|
|
5
|
+
branch on: the one worth asking again a moment later looked exactly like the one that will
|
|
6
|
+
say the same thing forever. It carries what Jobber reported it with — `cost`, `available`,
|
|
7
|
+
`maximum` and `restore_rate` — so a caller can tell a bucket that needed a second from a
|
|
8
|
+
query too big to ever fit in it
|
|
9
|
+
|
|
1
10
|
## [3.7.2] - 2026-08-17
|
|
2
11
|
|
|
3
12
|
- [Change] Jobber's token endpoint answers in prose, so that is all this reads. 3.7.1 kept a
|
data/README.md
CHANGED
|
@@ -113,13 +113,14 @@ job.completed_at # => 2026-05-18 11:36:13
|
|
|
113
113
|
|
|
114
114
|
Or walk the account's jobs, oldest first. Jobber is asked for a page at a time, and only
|
|
115
115
|
once the page before it runs out, so `first` costs one request where `to_a` costs as many
|
|
116
|
-
as the account has pages. A walk
|
|
117
|
-
[Rate limits](#rate-limits).
|
|
116
|
+
as the account has pages. A walk is priced by what its pages carry, and a long one can be
|
|
117
|
+
refused for it: see [Rate limits](#rate-limits).
|
|
118
118
|
|
|
119
119
|
```ruby
|
|
120
120
|
jobs = oauth.jobs # => an Enumerable of every job, nothing fetched yet
|
|
121
|
-
oauth.jobs.past # =>
|
|
122
|
-
oauth.jobs.upcoming # =>
|
|
121
|
+
oauth.jobs.past # => the ones dated before now, nothing fetched yet
|
|
122
|
+
oauth.jobs.upcoming # => the ones dated from now on, nothing fetched yet
|
|
123
|
+
oauth.jobs.past.ids # => %w[Z2lkOi8vS ...], every page of them, and nothing else about them
|
|
123
124
|
|
|
124
125
|
job = jobs.first
|
|
125
126
|
job.name # => 'Furnace tune-up', or the job's ID where nobody titled it. Never nil or empty
|
|
@@ -175,8 +176,9 @@ Walk the account's visits, oldest first, the same way as its jobs:
|
|
|
175
176
|
|
|
176
177
|
```ruby
|
|
177
178
|
visits = oauth.visits # => an Enumerable of every visit, nothing fetched yet
|
|
178
|
-
oauth.visits.upcoming # =>
|
|
179
|
-
oauth.visits.past # =>
|
|
179
|
+
oauth.visits.upcoming # => the ones dated from now on, nothing fetched yet
|
|
180
|
+
oauth.visits.past # => the ones dated before now, nothing fetched yet
|
|
181
|
+
oauth.visits.upcoming.ids # => %w[Z2lkOi8vS ...], every page of them, and nothing else
|
|
180
182
|
|
|
181
183
|
visit = visits.first
|
|
182
184
|
visit.id # => 'Z2lkOi8vS'
|
|
@@ -228,6 +230,16 @@ what happened, so the caller can decide:
|
|
|
228
230
|
Throttled (cost 1885, 1254 of 10000 available, restoring 500/s)
|
|
229
231
|
```
|
|
230
232
|
|
|
233
|
+
That one is a `Jbr::Retriable`, a `Jbr::Error` for a refusal worth asking again, carrying the
|
|
234
|
+
numbers to decide with:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
error.cost # => 1885, what the query was priced at
|
|
238
|
+
error.available # => 1254, what the bucket held when it was asked
|
|
239
|
+
error.maximum # => 10000, what the bucket holds when full — a cost above it never fits
|
|
240
|
+
error.restore_rate # => 500, points a second
|
|
241
|
+
```
|
|
242
|
+
|
|
231
243
|
That arrives as a `Jbr::Error`. 631 points short of a query the bucket holds five times over,
|
|
232
244
|
which a second would have refilled — worth asking again. A cost above `maximumAvailable` is
|
|
233
245
|
worth nothing but a smaller query. Either way the decision belongs to whoever called: from a
|
|
@@ -237,6 +249,15 @@ holding a transaction open.
|
|
|
237
249
|
Every connection the gem asks for is bounded, to keep a query on the affordable side of that:
|
|
238
250
|
twenty lines to a job, and twenty jobs or visits to a page.
|
|
239
251
|
|
|
252
|
+
`ids` is the cheap way to walk an account. It asks for the ID and nothing else, which prices
|
|
253
|
+
a row at a fraction of a record and buys a hundred of them to a page — so five times the
|
|
254
|
+
account is read for a fraction of the budget. Reach for it where each record is then read on
|
|
255
|
+
its own with `find`, one background job at a time:
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
oauth.jobs.past.ids.each { |id| ImportJob.perform_later id }
|
|
259
|
+
```
|
|
260
|
+
|
|
240
261
|
### Events
|
|
241
262
|
|
|
242
263
|
Parse the payload of a Jobber event webhook:
|
data/lib/graphql/client.rb
CHANGED
|
@@ -22,12 +22,23 @@ module GraphQL
|
|
|
22
22
|
raise Unauthorized, response.body if response.code == '401'
|
|
23
23
|
raise Error, response.body unless response.is_a? Net::HTTPSuccess
|
|
24
24
|
body = JSON.parse(response.body)
|
|
25
|
-
raise
|
|
25
|
+
raise refusal_for(body) if body['errors'].present?
|
|
26
26
|
body.fetch('data')
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
private
|
|
30
30
|
|
|
31
|
+
# Refused over cost where the endpoint names the code for it, or prices the query above
|
|
32
|
+
# what it says was left. Anything else is a refusal of the query itself.
|
|
33
|
+
def refusal_for(body)
|
|
34
|
+
cost = body['extensions'].to_h['cost'].to_h
|
|
35
|
+
available = cost['throttleStatus'].to_h['currentlyAvailable']
|
|
36
|
+
coded = body['errors'].any? { |error| error.to_h.dig('extensions', 'code') == 'THROTTLED' }
|
|
37
|
+
priced = available && cost['requestedQueryCost'].to_f > available.to_f
|
|
38
|
+
|
|
39
|
+
coded || priced ? Throttled.new(refusal(body), cost) : Error.new(refusal(body))
|
|
40
|
+
end
|
|
41
|
+
|
|
31
42
|
# What the endpoint refused, and — where it priced the refusal — what the query would have
|
|
32
43
|
# cost against what was available. `Throttled` on its own leaves a caller unable to tell a
|
|
33
44
|
# query too big to ever run from a bucket that a moment would have refilled.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module GraphQL
|
|
2
|
+
# An endpoint refusing a query for what it costs rather than for anything about the query
|
|
3
|
+
# itself. Worth telling apart: the same query is answered once the budget it is priced
|
|
4
|
+
# against has refilled.
|
|
5
|
+
class Throttled < Error
|
|
6
|
+
# @param message [String] what the endpoint said, with the numbers it said it with.
|
|
7
|
+
# @param cost [Hash] what it priced the query at, and the budget it priced it against.
|
|
8
|
+
def initialize(message, cost = {})
|
|
9
|
+
super message
|
|
10
|
+
@cost = cost
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @return [Hash] those numbers, in the endpoint's own words.
|
|
14
|
+
attr_reader :cost
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/jbr/jobs.rb
CHANGED
|
@@ -1,23 +1,13 @@
|
|
|
1
1
|
module Jbr
|
|
2
2
|
# The jobs on a Jobber account, oldest first, walked a page at a time.
|
|
3
3
|
class Jobs < Resource
|
|
4
|
-
include
|
|
4
|
+
include Includable, Listable
|
|
5
5
|
|
|
6
6
|
# What a job answers with wherever one is read, before anything it was asked to bring
|
|
7
7
|
# back with it.
|
|
8
8
|
FIELDS = 'id title instructions jobStatus total createdAt startAt completedAt ' \
|
|
9
9
|
'quote { id amounts { total } }'
|
|
10
10
|
|
|
11
|
-
# Every job on the account, past and future alike. Nothing is read until the walk starts,
|
|
12
|
-
# and a page is read only once the one before it runs out.
|
|
13
|
-
def each(&) = walk.each(&)
|
|
14
|
-
|
|
15
|
-
# @return [Enumerator<Job>] the jobs scheduled from now on.
|
|
16
|
-
def upcoming = walk from_now
|
|
17
|
-
|
|
18
|
-
# @return [Enumerator<Job>] the jobs that started before now.
|
|
19
|
-
def past = walk until_now
|
|
20
|
-
|
|
21
11
|
# Shadows Enumerable#find on purpose, the way Active Record does: a job is reached by the
|
|
22
12
|
# ID Jobber files it under, not by asking every job on the account whether it is the one.
|
|
23
13
|
# @param id [String] the Jobber ID of the job.
|
|
@@ -29,20 +19,7 @@ module Jbr
|
|
|
29
19
|
|
|
30
20
|
private
|
|
31
21
|
|
|
32
|
-
|
|
33
|
-
# what an includes brings back is charged for on top of every row of it — so a page of jobs
|
|
34
|
-
# carrying their lines, their property and its client priced past what a bucket holds. Half
|
|
35
|
-
# the page costs half the query and loses nothing, since a walk simply reads more pages.
|
|
36
|
-
def page
|
|
37
|
-
<<~GRAPHQL
|
|
38
|
-
query($after: String, $filter: JobFilterAttributes) {
|
|
39
|
-
jobs(first: 20, after: $after, filter: $filter) {
|
|
40
|
-
nodes { #{FIELDS} #{selections} }
|
|
41
|
-
pageInfo { hasNextPage endCursor }
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
GRAPHQL
|
|
45
|
-
end
|
|
22
|
+
def page = paged "#{FIELDS} #{selections}", PAGE
|
|
46
23
|
|
|
47
24
|
def one
|
|
48
25
|
<<~GRAPHQL
|
|
@@ -54,6 +31,8 @@ module Jbr
|
|
|
54
31
|
|
|
55
32
|
def field = 'jobs'
|
|
56
33
|
|
|
34
|
+
def filtered = 'JobFilterAttributes'
|
|
35
|
+
|
|
57
36
|
def item(node) = Job.new node: node
|
|
58
37
|
end
|
|
59
38
|
end
|
data/lib/jbr/listable.rb
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# What a list of records answers, and how much of an account it costs to ask. Jobber prices
|
|
3
|
+
# a query by the page it asks for and by what every row of that page carries, so a list is
|
|
4
|
+
# read either as records or as the IDs alone, each with a page sized to what it carries.
|
|
5
|
+
module Listable
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
# Records a page, not forty and not a hundred: what an includes brings back is charged for
|
|
9
|
+
# on top of every row of it, so a page of jobs carrying their lines, their property and its
|
|
10
|
+
# client priced past what a bucket holds. Half the page costs half the query and loses
|
|
11
|
+
# nothing, since a walk simply reads more pages.
|
|
12
|
+
PAGE = 20
|
|
13
|
+
|
|
14
|
+
# IDs a page, which the same budget affords five times over where a row carries one field
|
|
15
|
+
# and no nesting at all.
|
|
16
|
+
IDS_PAGE = 100
|
|
17
|
+
|
|
18
|
+
# Every record the list is narrowed to, oldest first. Nothing is read until the walk
|
|
19
|
+
# starts, and a page is read only once the one before it runs out.
|
|
20
|
+
def each(&) = walk(page).each(&)
|
|
21
|
+
|
|
22
|
+
# @return [Listable] the same list, narrowed to what is scheduled from now on.
|
|
23
|
+
def upcoming = narrowed from_now
|
|
24
|
+
|
|
25
|
+
# @return [Listable] the same list, narrowed to what started before now.
|
|
26
|
+
def past = narrowed until_now
|
|
27
|
+
|
|
28
|
+
# The ID Jobber files each record under, and nothing else about it: the cheapest question
|
|
29
|
+
# an account can be walked with, and the one to ask where every record is then read on its
|
|
30
|
+
# own through {#find}.
|
|
31
|
+
# @return [Array<String>] every ID in the list, every page of them read.
|
|
32
|
+
def ids = walk(ids_page).map(&:id)
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def narrowed(filter) = self.class.new(oauth: @oauth, includes: @includes, filter: filter)
|
|
37
|
+
|
|
38
|
+
# The two halves of a schedule, split at the moment they are asked for rather than per
|
|
39
|
+
# page: read page by page the boundary would slide, and something could cross it unseen.
|
|
40
|
+
def from_now = { startAt: { after: Time.now.iso8601 } }
|
|
41
|
+
|
|
42
|
+
def until_now = { startAt: { before: Time.now.iso8601 } }
|
|
43
|
+
|
|
44
|
+
# Every record a paged query answers, one at a time, a page read only once the one before
|
|
45
|
+
# it runs out. The filter is data: narrowed to nothing, the query narrows nothing.
|
|
46
|
+
def walk(statement)
|
|
47
|
+
Enumerator.new do |yielder|
|
|
48
|
+
after = nil
|
|
49
|
+
loop do
|
|
50
|
+
answered = @oauth.query statement, variables: { after: after, filter: @filter }.compact
|
|
51
|
+
current = answered.fetch field, {}
|
|
52
|
+
current.fetch('nodes', []).each { |node| yielder << item(node) }
|
|
53
|
+
break unless current.dig 'pageInfo', 'hasNextPage'
|
|
54
|
+
|
|
55
|
+
after = current.dig 'pageInfo', 'endCursor'
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def ids_page = paged 'id', IDS_PAGE
|
|
61
|
+
|
|
62
|
+
def paged(fields, size)
|
|
63
|
+
<<~GRAPHQL
|
|
64
|
+
query($after: String, $filter: #{filtered}) {
|
|
65
|
+
#{field}(first: #{size}, after: $after, filter: $filter) {
|
|
66
|
+
nodes { #{fields} }
|
|
67
|
+
pageInfo { hasNextPage endCursor }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
GRAPHQL
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/jbr/mock/jobs.rb
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
-
# The jobs an app under test asked {Jbr.mock} to answer with.
|
|
2
|
+
# The jobs an app under test asked {Jbr.mock} to answer with. Only the walk is mocked:
|
|
3
|
+
# narrowing a list, and reading it as records or as IDs, is the same code a real one runs.
|
|
3
4
|
class Mock::Jobs < Jobs
|
|
4
5
|
# The job filed under that ID where the app listed one, and otherwise the single job it
|
|
5
6
|
# named — which is every app that mocks a lookup without mocking a list.
|
|
6
7
|
# @return [Mock::Job] the job asked for.
|
|
7
8
|
def find(id) = Mock::Job.new node: listed(id) || Jbr.mock.job
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
def each(&) = mocked(Jbr.mock.jobs).each(&)
|
|
11
|
-
|
|
12
|
-
# @return [Enumerator<Mock::Job>] those it dated from now on, and any it left undated.
|
|
13
|
-
def upcoming = mocked Jbr.mock.jobs.reject { |job| started? job }
|
|
10
|
+
private
|
|
14
11
|
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
def walk(_statement)
|
|
13
|
+
Enumerator.new { |yielder| selected.each { |job| yielder << Mock::Job.new(node: job) } }
|
|
14
|
+
end
|
|
17
15
|
|
|
18
|
-
|
|
16
|
+
# The half of the schedule the list was narrowed to, from what the app dated each job.
|
|
17
|
+
# One it left undated is one nothing has started, so it counts as upcoming.
|
|
18
|
+
def selected
|
|
19
|
+
return Jbr.mock.jobs unless @filter
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
started = @filter.dig(:startAt, :before).present?
|
|
22
|
+
Jbr.mock.jobs.select { |job| started?(job) == started }
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
def started?(job) = job[:scheduled_at] ? job[:scheduled_at] <= Time.now : false
|
data/lib/jbr/mock/visits.rb
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
-
# The visits an app under test asked {Jbr.mock} to answer with.
|
|
2
|
+
# The visits an app under test asked {Jbr.mock} to answer with. Only the walk is mocked:
|
|
3
|
+
# narrowing a list, and reading it as records or as IDs, is the same code a real one runs.
|
|
3
4
|
class Mock::Visits < Visits
|
|
4
|
-
# @return [Enumerator<Mock::Visit>] every visit the app named.
|
|
5
|
-
def each(&) = mocked(Jbr.mock.visits).each(&)
|
|
6
|
-
|
|
7
|
-
# @return [Enumerator<Mock::Visit>] those it dated from now on, and any it left undated.
|
|
8
|
-
def upcoming = mocked Jbr.mock.visits.reject { |visit| started? visit }
|
|
9
|
-
|
|
10
|
-
# @return [Enumerator<Mock::Visit>] those it dated before now.
|
|
11
|
-
def past = mocked Jbr.mock.visits.select { |visit| started? visit }
|
|
12
|
-
|
|
13
5
|
# @return [Mock::Visit] the visit the app listed under that ID.
|
|
14
6
|
def find(id) = Mock::Visit.new node: listed(id)
|
|
15
7
|
|
|
16
8
|
private
|
|
17
9
|
|
|
18
|
-
def
|
|
19
|
-
Enumerator.new { |yielder|
|
|
10
|
+
def walk(_statement)
|
|
11
|
+
Enumerator.new { |yielder| selected.each { |it| yielder << Mock::Visit.new(node: it) } }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# The half of the schedule the list was narrowed to, from what the app dated each visit.
|
|
15
|
+
# One it left undated is one nothing has started, so it counts as upcoming.
|
|
16
|
+
def selected
|
|
17
|
+
return Jbr.mock.visits unless @filter
|
|
18
|
+
|
|
19
|
+
started = @filter.dig(:startAt, :before).present?
|
|
20
|
+
Jbr.mock.visits.select { |visit| started?(visit) == started }
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
def started?(visit) = visit[:starts_at] ? visit[:starts_at] <= Time.now : false
|
data/lib/jbr/oauth.rb
CHANGED
|
@@ -47,6 +47,8 @@ module Jbr
|
|
|
47
47
|
client.query statement, variables: variables
|
|
48
48
|
rescue GraphQL::Unauthorized
|
|
49
49
|
refresh ? retry : {}
|
|
50
|
+
rescue GraphQL::Throttled => error
|
|
51
|
+
raise Retriable.new(error.message, error.cost)
|
|
50
52
|
rescue GraphQL::Error => error
|
|
51
53
|
# The transport's own class never leaves the gem: a caller told to rescue `Jbr::Error`
|
|
52
54
|
# was not catching a throttle, a 500 or an unreadable answer, and had its own job blow
|
data/lib/jbr/resource.rb
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
module Jbr
|
|
2
2
|
# What every Jobber resource shares: the node Jobber answered with, the credentials it was
|
|
3
|
-
# read through, and
|
|
3
|
+
# read through, and what a list of them was asked and narrowed to.
|
|
4
4
|
class Resource
|
|
5
5
|
# @param oauth [OAuth] the credentials to reach Jobber with, where reaching it is needed.
|
|
6
6
|
# @param node [Hash] the record as Jobber answered it.
|
|
7
7
|
# @param includes [Hash] what a list was asked to bring back beside its records.
|
|
8
|
-
|
|
8
|
+
# @param filter [Hash] what a list was narrowed to, in the shape Jobber filters by.
|
|
9
|
+
def initialize(oauth: nil, node: {}, includes: {}, filter: nil)
|
|
9
10
|
@oauth = oauth
|
|
10
11
|
@node = node
|
|
11
12
|
@id = node['id']
|
|
12
13
|
@includes = includes
|
|
14
|
+
@filter = filter
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
# @return [String, nil] the Jobber ID, from the node it came in or once one was created.
|
|
@@ -20,27 +22,5 @@ module Jbr
|
|
|
20
22
|
# @return [Time, nil] what Jobber answered under a key, as a time. An empty answer is no
|
|
21
23
|
# answer: Time.iso8601 raises on one, where nothing at all it simply has none of.
|
|
22
24
|
def time(key) = (Time.iso8601 @node[key] if @node[key].present?)
|
|
23
|
-
|
|
24
|
-
# Every item a paged query answers, one at a time, a page read only once the one before
|
|
25
|
-
# it runs out. The filter is data: handed none, the query narrows nothing.
|
|
26
|
-
def walk(filter = nil)
|
|
27
|
-
Enumerator.new do |yielder|
|
|
28
|
-
after = nil
|
|
29
|
-
loop do
|
|
30
|
-
answered = @oauth.query(page, variables: { after: after, filter: filter }.compact)
|
|
31
|
-
current = answered.fetch field, {}
|
|
32
|
-
current.fetch('nodes', []).each { |node| yielder << item(node) }
|
|
33
|
-
break unless current.dig 'pageInfo', 'hasNextPage'
|
|
34
|
-
|
|
35
|
-
after = current.dig 'pageInfo', 'endCursor'
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# The two halves of a schedule, split at the moment they are asked for rather than per
|
|
41
|
-
# page: read page by page the boundary would slide, and something could cross it unseen.
|
|
42
|
-
def from_now = { startAt: { after: Time.now.iso8601 } }
|
|
43
|
-
|
|
44
|
-
def until_now = { startAt: { before: Time.now.iso8601 } }
|
|
45
25
|
end
|
|
46
26
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# Jobber refusing a query over what it costs rather than over anything about the query. The
|
|
3
|
+
# bucket it is priced against refills, so the same question asked later is answered.
|
|
4
|
+
class Retriable < Error
|
|
5
|
+
# @param message [String] what Jobber said, with the numbers it said it with.
|
|
6
|
+
# @param cost [Hash] the `requestedQueryCost` and the `throttleStatus` beside it.
|
|
7
|
+
def initialize(message, cost = {})
|
|
8
|
+
super message
|
|
9
|
+
status = cost['throttleStatus'].to_h
|
|
10
|
+
@cost = cost['requestedQueryCost']
|
|
11
|
+
@available = status['currentlyAvailable']
|
|
12
|
+
@maximum = status['maximumAvailable']
|
|
13
|
+
@restore_rate = status['restoreRate']
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# What the query would have cost, the budget it was priced against, and how fast that
|
|
17
|
+
# budget refills. A cost above the maximum is one no waiting will pay for.
|
|
18
|
+
attr_reader :cost, :available, :maximum, :restore_rate
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/jbr/version.rb
CHANGED
data/lib/jbr/visits.rb
CHANGED
|
@@ -1,21 +1,11 @@
|
|
|
1
1
|
module Jbr
|
|
2
2
|
# The visits on a Jobber account, oldest first, walked a page at a time.
|
|
3
3
|
class Visits < Resource
|
|
4
|
-
include
|
|
4
|
+
include Includable, Listable
|
|
5
5
|
|
|
6
6
|
# What a visit answers with, before anything it was asked to bring back with it.
|
|
7
7
|
FIELDS = 'id title startAt endAt allDay clientConfirmed job { id }'
|
|
8
8
|
|
|
9
|
-
# Every visit on the account, past and future alike. Nothing is read until the walk
|
|
10
|
-
# starts, and a page is read only once the one before it runs out.
|
|
11
|
-
def each(&) = walk.each(&)
|
|
12
|
-
|
|
13
|
-
# @return [Enumerator<Visit>] the visits scheduled from now on.
|
|
14
|
-
def upcoming = walk from_now
|
|
15
|
-
|
|
16
|
-
# @return [Enumerator<Visit>] the visits that started before now.
|
|
17
|
-
def past = walk until_now
|
|
18
|
-
|
|
19
9
|
# Shadows Enumerable#find on purpose, the way jobs do: a visit is reached by the ID Jobber
|
|
20
10
|
# files it under, not by asking every visit on the account whether it is the one.
|
|
21
11
|
# @param id [String] the Jobber ID of the visit.
|
|
@@ -27,6 +17,8 @@ module Jbr
|
|
|
27
17
|
|
|
28
18
|
private
|
|
29
19
|
|
|
20
|
+
def page = paged "#{FIELDS} #{selections}", PAGE
|
|
21
|
+
|
|
30
22
|
def one
|
|
31
23
|
<<~GRAPHQL
|
|
32
24
|
query($id: EncodedId!) {
|
|
@@ -35,21 +27,10 @@ module Jbr
|
|
|
35
27
|
GRAPHQL
|
|
36
28
|
end
|
|
37
29
|
|
|
38
|
-
# Twenty a page, the same as jobs: Jobber prices a query by its page size, and what an
|
|
39
|
-
# includes brings back is charged for on top of every row of it.
|
|
40
|
-
def page
|
|
41
|
-
<<~GRAPHQL
|
|
42
|
-
query($after: String, $filter: VisitFilterAttributes) {
|
|
43
|
-
visits(first: 20, after: $after, filter: $filter) {
|
|
44
|
-
nodes { #{FIELDS} #{selections} }
|
|
45
|
-
pageInfo { hasNextPage endCursor }
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
GRAPHQL
|
|
49
|
-
end
|
|
50
|
-
|
|
51
30
|
def field = 'visits'
|
|
52
31
|
|
|
32
|
+
def filtered = 'VisitFilterAttributes'
|
|
33
|
+
|
|
53
34
|
def item(node) = Visit.new node: node
|
|
54
35
|
end
|
|
55
36
|
end
|
data/lib/jbr.rb
CHANGED
|
@@ -11,6 +11,7 @@ require 'active_support/core_ext/array/conversions'
|
|
|
11
11
|
|
|
12
12
|
require 'graphql/error'
|
|
13
13
|
require 'graphql/unauthorized'
|
|
14
|
+
require 'graphql/throttled'
|
|
14
15
|
require 'graphql/client'
|
|
15
16
|
|
|
16
17
|
require 'jbr/mock'
|
|
@@ -18,6 +19,7 @@ require 'jbr/mock'
|
|
|
18
19
|
require 'jbr/url'
|
|
19
20
|
require 'jbr/error'
|
|
20
21
|
require 'jbr/refused'
|
|
22
|
+
require 'jbr/retriable'
|
|
21
23
|
require 'jbr/token'
|
|
22
24
|
require 'jbr/refreshing'
|
|
23
25
|
# Phone before Cliental, and Cliental before the records that include it: what each asks
|
|
@@ -34,6 +36,7 @@ require 'jbr/account'
|
|
|
34
36
|
require 'jbr/property'
|
|
35
37
|
require 'jbr/properted'
|
|
36
38
|
require 'jbr/includable'
|
|
39
|
+
require 'jbr/listable'
|
|
37
40
|
# LineItem before Itemized, and both before Job: the lines a job is made of are asked for
|
|
38
41
|
# by a constant the include reads as it loads.
|
|
39
42
|
require 'jbr/line_item'
|
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.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Claudio Baccigalupo
|
|
@@ -106,6 +106,7 @@ files:
|
|
|
106
106
|
- README.md
|
|
107
107
|
- lib/graphql/client.rb
|
|
108
108
|
- lib/graphql/error.rb
|
|
109
|
+
- lib/graphql/throttled.rb
|
|
109
110
|
- lib/graphql/unauthorized.rb
|
|
110
111
|
- lib/jbr.rb
|
|
111
112
|
- lib/jbr/account.rb
|
|
@@ -119,6 +120,7 @@ files:
|
|
|
119
120
|
- lib/jbr/job.rb
|
|
120
121
|
- lib/jbr/jobs.rb
|
|
121
122
|
- lib/jbr/line_item.rb
|
|
123
|
+
- lib/jbr/listable.rb
|
|
122
124
|
- lib/jbr/mock.rb
|
|
123
125
|
- lib/jbr/mock/account.rb
|
|
124
126
|
- lib/jbr/mock/client.rb
|
|
@@ -144,6 +146,7 @@ files:
|
|
|
144
146
|
- lib/jbr/refused.rb
|
|
145
147
|
- lib/jbr/request.rb
|
|
146
148
|
- lib/jbr/resource.rb
|
|
149
|
+
- lib/jbr/retriable.rb
|
|
147
150
|
- lib/jbr/token.rb
|
|
148
151
|
- lib/jbr/url.rb
|
|
149
152
|
- lib/jbr/version.rb
|