jbr 1.2.0 → 2.0.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/.rubocop.yml +59 -0
- data/CHANGELOG.md +14 -0
- data/README.md +9 -6
- data/lib/graphql/client.rb +2 -1
- data/lib/jbr/account.rb +4 -1
- data/lib/jbr/client.rb +19 -33
- data/lib/jbr/error.rb +1 -0
- data/lib/jbr/event.rb +1 -0
- data/lib/jbr/invoice.rb +6 -1
- data/lib/jbr/job.rb +6 -1
- data/lib/jbr/mock/account.rb +2 -0
- data/lib/jbr/mock/invoice.rb +3 -0
- data/lib/jbr/mock/job.rb +3 -0
- data/lib/jbr/mock/oauth.rb +5 -3
- data/lib/jbr/mock/quote.rb +2 -0
- data/lib/jbr/mock/request.rb +2 -0
- data/lib/jbr/mock/url.rb +2 -0
- data/lib/jbr/mock.rb +2 -0
- data/lib/jbr/mocking.rb +13 -4
- data/lib/jbr/oauth.rb +33 -13
- data/lib/jbr/property.rb +55 -0
- data/lib/jbr/quote.rb +6 -1
- data/lib/jbr/request.rb +5 -2
- data/lib/jbr/resource.rb +3 -0
- data/lib/jbr/url.rb +6 -3
- data/lib/jbr/version.rb +3 -3
- data/lib/jbr.rb +1 -2
- metadata +74 -3
- data/lib/jbr/configuration.rb +0 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c3baeab4ed52ddaf9e86a1086b34b84da47a9bce32a46e05036145dc1c9ca86
|
|
4
|
+
data.tar.gz: e0c89dd53a6e64a15bba3b748b1689f2cc063030ba64ed13b18364d1768a6f99
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cd207ad99f2769ae55bcf2a4f5b149118bafa49f6638d18a4e4a9661ff5729cb1fd1398b67af68cfb17dd251a851b46a350835621a34ed3f888f3676e774d621
|
|
7
|
+
data.tar.gz: d8dc7694dff3aa8ef51480ab51500c94903cf0118f6d075e9b8ada9fae761dcb7f91f133a9eb7ee4894c33a18fab59645ae138ccd4aec367fa5c0ccbf50c0a9f
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Omakase Ruby styling for Rails
|
|
2
|
+
inherit_gem: { rubocop-rails-omakase: rubocop.yml }
|
|
3
|
+
|
|
4
|
+
# The omakase base disables whole departments and re-enables a chosen subset, so
|
|
5
|
+
# every cop it does not name needs an explicit 'Enabled: true' below.
|
|
6
|
+
|
|
7
|
+
AllCops:
|
|
8
|
+
# Adopt every cop a new release brings, rather than pinning to today's set
|
|
9
|
+
NewCops: enable
|
|
10
|
+
# An extension we decline is declined in this file, not merely ignored
|
|
11
|
+
SuggestExtensions: false
|
|
12
|
+
|
|
13
|
+
# We never freeze a string, so the magic comment has nothing to say
|
|
14
|
+
Style/FrozenStringLiteralComment:
|
|
15
|
+
Enabled: true
|
|
16
|
+
EnforcedStyle: never
|
|
17
|
+
|
|
18
|
+
# The cop demands '.freeze' on string constants and cannot skip them
|
|
19
|
+
Style/MutableConstant:
|
|
20
|
+
Enabled: false
|
|
21
|
+
|
|
22
|
+
Style/StringLiterals:
|
|
23
|
+
EnforcedStyle: single_quotes
|
|
24
|
+
|
|
25
|
+
Style/StringLiteralsInInterpolation:
|
|
26
|
+
Enabled: true
|
|
27
|
+
EnforcedStyle: single_quotes
|
|
28
|
+
|
|
29
|
+
Style/TrailingCommaInArrayLiteral:
|
|
30
|
+
EnforcedStyleForMultiline: consistent_comma
|
|
31
|
+
|
|
32
|
+
Style/TrailingCommaInHashLiteral:
|
|
33
|
+
EnforcedStyleForMultiline: consistent_comma
|
|
34
|
+
|
|
35
|
+
Layout/CaseIndentation:
|
|
36
|
+
EnforcedStyle: end
|
|
37
|
+
IndentOneStep: true
|
|
38
|
+
|
|
39
|
+
# 'private' sits at the level of 'class', so it reads as a divider
|
|
40
|
+
Layout/AccessModifierIndentation:
|
|
41
|
+
Enabled: true
|
|
42
|
+
EnforcedStyle: outdent
|
|
43
|
+
|
|
44
|
+
# One 'include' statement carries the list, split only when it will not fit
|
|
45
|
+
Style/MixinGrouping:
|
|
46
|
+
Enabled: true
|
|
47
|
+
EnforcedStyle: grouped
|
|
48
|
+
|
|
49
|
+
Layout/LineLength:
|
|
50
|
+
Enabled: true
|
|
51
|
+
Max: 100
|
|
52
|
+
|
|
53
|
+
# Omit parentheses on a call's arguments, keeping the inner ones parsing needs -- except
|
|
54
|
+
# where the call IS the condition, after 'if', 'unless', 'while', 'until' or in a ternary,
|
|
55
|
+
# which keeps them so the reader sees where the argument ends and the branch begins.
|
|
56
|
+
# 'omit_parentheses' has no option for that exception and an inline disable costs three
|
|
57
|
+
# lines per site, so the cop is declined and the rule held in review.
|
|
58
|
+
Style/MethodCallWithArgsParentheses:
|
|
59
|
+
Enabled: false
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [2.0.0] - 2026-08-07
|
|
2
|
+
|
|
3
|
+
- [Fix] Require nothing but the standard library: to_query, present?, pluck,
|
|
4
|
+
stringify_keys and Time.current were ActiveSupport calls the gemspec never declared,
|
|
5
|
+
so `require 'jbr'` raised outside Rails
|
|
6
|
+
- [Fix] Reuse the property already on a client's file instead of adding a duplicate on
|
|
7
|
+
every request: the lookup read the address as `street`, while the comparison built it
|
|
8
|
+
as `street1`, so it never matched. The match is made on street and ZIP; city and state
|
|
9
|
+
are written but not matched, since Jobber holds whatever was typed
|
|
10
|
+
- [Fix] Client#create no longer raises when Jobber answers without clientProperties
|
|
11
|
+
- [Feature] Test every line, with SimpleCov failing the suite below 100% coverage
|
|
12
|
+
- [Change] Extract Jbr::Property from Jbr::Client
|
|
13
|
+
- [Change] Remove the unused Jbr::Configuration class
|
|
14
|
+
|
|
1
15
|
## [1.2.0] - 2026-06-09
|
|
2
16
|
|
|
3
17
|
- [New] Create a Property with a Request if needed
|
data/README.md
CHANGED
|
@@ -1,5 +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.
|
|
4
|
+
|
|
3
5
|
## Available methods
|
|
4
6
|
|
|
5
7
|
### Credentials
|
|
@@ -9,6 +11,7 @@ Generate the URL for Jobber users to authorize the app:
|
|
|
9
11
|
```ruby
|
|
10
12
|
url = Jbr.oauth_url_for redirect_uri:, state:
|
|
11
13
|
url # => 'https://api.getjobber.com/api/oauth/authorize?state=...&redirect_uri=...'
|
|
14
|
+
```
|
|
12
15
|
|
|
13
16
|
Create credentials with a code and a redirect URI:
|
|
14
17
|
|
|
@@ -19,17 +22,17 @@ oauth = Jbr.create_oauth code:, redirect_uri:
|
|
|
19
22
|
Initialize with existing credentials:
|
|
20
23
|
|
|
21
24
|
```ruby
|
|
22
|
-
oauth = Jbr.oauth_for access_token:, refresh_token
|
|
25
|
+
oauth = Jbr.oauth_for access_token:, refresh_token:, expires_at:, account_id:
|
|
23
26
|
```
|
|
24
27
|
|
|
25
28
|
Access OAuth attributes:
|
|
26
29
|
|
|
27
|
-
```
|
|
30
|
+
```ruby
|
|
28
31
|
oauth.access_token # => 'eyJhbGciOiJIUzI1NiJ'
|
|
29
32
|
oauth.refresh_token # => 'ea02775958c5fca28d'
|
|
30
33
|
oauth.expires_at # => 2026-05-22 14:32:53
|
|
31
34
|
oauth.account_id # => 'Z2lkOi8vSm9iYmV'
|
|
32
|
-
|
|
35
|
+
```
|
|
33
36
|
|
|
34
37
|
Revoke credentials:
|
|
35
38
|
|
|
@@ -90,8 +93,8 @@ Parse the payload of a Jobber event webhook:
|
|
|
90
93
|
```ruby
|
|
91
94
|
event = Jbr::Event.new data: { webHookEvent: { topic: 'JOB_CREATE', appId: 'app-1',
|
|
92
95
|
accountId: 'account-1', itemId: 'job-1', occurredAt: '2026-05-22T15:46:33Z' } }
|
|
93
|
-
event.account_id
|
|
94
|
-
event.item_id
|
|
96
|
+
event.account_id # => 'account-1'
|
|
97
|
+
event.item_id # => 'job-1'
|
|
95
98
|
```
|
|
96
99
|
|
|
97
100
|
## Available mocks
|
|
@@ -115,7 +118,7 @@ Jbr.mock.oauth_error = 'Flow rejected'
|
|
|
115
118
|
Mock a custom redirect URL:
|
|
116
119
|
|
|
117
120
|
```ruby
|
|
118
|
-
Jbr.mock.
|
|
121
|
+
Jbr.mock.oauth_url = 'https://example.com'
|
|
119
122
|
```
|
|
120
123
|
|
|
121
124
|
### Requests
|
data/lib/graphql/client.rb
CHANGED
|
@@ -22,7 +22,8 @@ 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
|
-
|
|
25
|
+
errors = body['errors'] || []
|
|
26
|
+
raise Error, errors.map { |error| error['message'] }.join('; ') unless errors.empty?
|
|
26
27
|
body.fetch('data')
|
|
27
28
|
end
|
|
28
29
|
|
data/lib/jbr/account.rb
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
+
# The Jobber account a set of credentials belongs to.
|
|
2
3
|
class Account < Resource
|
|
3
|
-
|
|
4
|
+
# The query that reads the account behind the current credentials.
|
|
5
|
+
FIND = <<~GRAPHQL
|
|
4
6
|
{ account { id } }
|
|
5
7
|
GRAPHQL
|
|
6
8
|
|
|
9
|
+
# @return [String] the account ID, read once and remembered.
|
|
7
10
|
def id
|
|
8
11
|
@id ||= @oauth.query(FIND).dig 'account', 'id'
|
|
9
12
|
end
|
data/lib/jbr/client.rb
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
+
# A person a Jobber user works for, and the property the work happens at.
|
|
2
3
|
class Client < Resource
|
|
3
|
-
|
|
4
|
+
# The query that finds a client by phone, with the properties already on file.
|
|
5
|
+
LOOKUP = <<~GRAPHQL
|
|
4
6
|
query($searchTerm: String!) {
|
|
5
7
|
clientPhones(first: 1, searchTerm: $searchTerm) { nodes {
|
|
6
|
-
client { id updatedAt clientProperties { nodes { id address {
|
|
8
|
+
client { id updatedAt clientProperties { nodes { id address { street1 city province postalCode } }} }
|
|
7
9
|
} }
|
|
8
10
|
}
|
|
9
11
|
GRAPHQL
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
# The mutation that opens a client, with a first property when an address is given.
|
|
14
|
+
CREATE = <<~GRAPHQL
|
|
12
15
|
mutation($input: ClientCreateInput!) {
|
|
13
16
|
clientCreate(input: $input) {
|
|
14
17
|
client { id clientProperties(first: 1) { nodes { id } } }
|
|
@@ -17,15 +20,7 @@ module Jbr
|
|
|
17
20
|
}
|
|
18
21
|
GRAPHQL
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
mutation propertyCreateMutation($clientId: EncodedId!, $input: PropertyCreateInput!) {
|
|
22
|
-
propertyCreate(clientId: $clientId, input: $input) {
|
|
23
|
-
properties { id }
|
|
24
|
-
userErrors { message }
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
GRAPHQL
|
|
28
|
-
|
|
23
|
+
# @return [String, nil] the property the work happens at.
|
|
29
24
|
attr_reader :property_id
|
|
30
25
|
|
|
31
26
|
# Create a client instance with the provided attributes.
|
|
@@ -39,6 +34,9 @@ module Jbr
|
|
|
39
34
|
self.tap { @create_params = params }
|
|
40
35
|
end
|
|
41
36
|
|
|
37
|
+
# Reach the client behind a phone number, opening one if Jobber has none.
|
|
38
|
+
# @param phone [String] the number to match on.
|
|
39
|
+
# @return [Client] itself.
|
|
42
40
|
def find_or_create_by(phone:)
|
|
43
41
|
find_by_phone(phone) || create
|
|
44
42
|
self
|
|
@@ -54,17 +52,9 @@ module Jbr
|
|
|
54
52
|
return unless recent
|
|
55
53
|
|
|
56
54
|
@id = recent.dig 'client', 'id'
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
extract_address_from(@create_params[:address]).stringify_keys == property['address']
|
|
61
|
-
end
|
|
62
|
-
@property_id = if existing_property
|
|
63
|
-
existing_property['id']
|
|
64
|
-
else
|
|
65
|
-
property = @oauth.query CREATE_PROPERTY, variables: { clientId: @id, input: { properties: [ { address: extract_address_from(@create_params[:address]) }] } }
|
|
66
|
-
(property&.dig('propertyCreate', 'properties')&.first || {})['id']
|
|
67
|
-
end
|
|
55
|
+
@property_id = Property.new(oauth: @oauth).find_or_create_for client_id: @id,
|
|
56
|
+
address: @create_params[:address],
|
|
57
|
+
existing: recent.dig('client', 'clientProperties', 'nodes') || []
|
|
68
58
|
true
|
|
69
59
|
end
|
|
70
60
|
|
|
@@ -72,24 +62,20 @@ module Jbr
|
|
|
72
62
|
output = @oauth.query CREATE, variables: { input: input }
|
|
73
63
|
@id = output.dig 'clientCreate', 'client', 'id'
|
|
74
64
|
|
|
75
|
-
properties = output.dig
|
|
65
|
+
properties = output.dig('clientCreate', 'client', 'clientProperties', 'nodes') || []
|
|
76
66
|
@property_id = (properties.first || {})['id']
|
|
77
67
|
end
|
|
78
68
|
|
|
79
69
|
def input
|
|
70
|
+
address, email = @create_params[:address], @create_params[:email]
|
|
80
71
|
{ firstName: @create_params[:first_name],
|
|
81
72
|
lastName: @create_params[:last_name],
|
|
82
|
-
properties: ([{ address:
|
|
83
|
-
phones: [{ number: @create_params[:phone], primary: true }],
|
|
84
|
-
emails: ([{ address:
|
|
73
|
+
properties: ([ { address: Property.address_from(address) } ] if present?(address)),
|
|
74
|
+
phones: [ { number: @create_params[:phone], primary: true } ],
|
|
75
|
+
emails: ([ { address: email, primary: true } ] if present?(email)),
|
|
85
76
|
}.compact
|
|
86
77
|
end
|
|
87
78
|
|
|
88
|
-
def
|
|
89
|
-
{
|
|
90
|
-
street1: fields[:street], city: fields[:city],
|
|
91
|
-
province: fields[:state], postalCode: fields[:zip]
|
|
92
|
-
}.compact
|
|
93
|
-
end
|
|
79
|
+
def present?(value) = !value.nil? && !(value.respond_to?(:empty?) && value.empty?)
|
|
94
80
|
end
|
|
95
81
|
end
|
data/lib/jbr/error.rb
CHANGED
data/lib/jbr/event.rb
CHANGED
data/lib/jbr/invoice.rb
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
+
# A bill a Jobber user issued for finished work.
|
|
2
3
|
class Invoice < Resource
|
|
3
|
-
|
|
4
|
+
# The query that reads one invoice, its total, its date and the job it bills.
|
|
5
|
+
FIND = <<~GRAPHQL
|
|
4
6
|
query($id: EncodedId!) {
|
|
5
7
|
invoice(id: $id) { id total invoiceStatus issuedDate
|
|
6
8
|
jobs { nodes { id completedAt } } }
|
|
7
9
|
}
|
|
8
10
|
GRAPHQL
|
|
9
11
|
|
|
12
|
+
# @return [String, nil] the ID of the job billed, and the amount as Jobber writes it.
|
|
10
13
|
attr_reader :job_id, :total
|
|
11
14
|
|
|
15
|
+
# @param id [String] the Jobber ID of the invoice.
|
|
16
|
+
# @return [Invoice, nil] itself, or nil when the invoice is missing or still a draft.
|
|
12
17
|
def find(id)
|
|
13
18
|
output = @oauth.query FIND, variables: { id: id }
|
|
14
19
|
return unless invoice = output['invoice']
|
data/lib/jbr/job.rb
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
+
# Work a Jobber user accepted and scheduled.
|
|
2
3
|
class Job < Resource
|
|
3
|
-
|
|
4
|
+
# The query that reads one job, its quote and its two timestamps.
|
|
5
|
+
FIND = <<~GRAPHQL
|
|
4
6
|
query($id: EncodedId!) {
|
|
5
7
|
job(id: $id) { id quote { id } startAt completedAt }
|
|
6
8
|
}
|
|
7
9
|
GRAPHQL
|
|
8
10
|
|
|
11
|
+
# @return [String, nil] the ID of the quote the job was won with.
|
|
9
12
|
attr_reader :quote_id
|
|
10
13
|
|
|
14
|
+
# @param id [String] the Jobber ID of the job.
|
|
15
|
+
# @return [Job, nil] itself, or nil when Jobber has no such job.
|
|
11
16
|
def find(id)
|
|
12
17
|
output = @oauth.query FIND, variables: { id: id }
|
|
13
18
|
return unless job = output['job']
|
data/lib/jbr/mock/account.rb
CHANGED
data/lib/jbr/mock/invoice.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
+
# An invoice that reads from {Jbr.mock} instead of Jobber.
|
|
2
3
|
class Mock::Invoice < Invoice
|
|
4
|
+
# @return [Mock::Invoice] itself, carrying the mocked IDs and total.
|
|
3
5
|
def find(_)
|
|
4
6
|
@id = Jbr.mock.invoice[:id]
|
|
5
7
|
@job_id = Jbr.mock.invoice[:job_id]
|
|
@@ -8,6 +10,7 @@ module Jbr
|
|
|
8
10
|
self
|
|
9
11
|
end
|
|
10
12
|
|
|
13
|
+
# @return [Time, nil] the dates the app asked for.
|
|
11
14
|
def issued_at = Jbr.mock.invoice[:issued_at]
|
|
12
15
|
|
|
13
16
|
def completed_at = Jbr.mock.invoice[:completed_at]
|
data/lib/jbr/mock/job.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
+
# A job that reads from {Jbr.mock} instead of Jobber.
|
|
2
3
|
class Mock::Job < Job
|
|
4
|
+
# @return [Mock::Job] itself, carrying the mocked IDs.
|
|
3
5
|
def find(_)
|
|
4
6
|
@id = Jbr.mock.job[:id]
|
|
5
7
|
@quote_id = Jbr.mock.job[:quote_id]
|
|
@@ -7,6 +9,7 @@ module Jbr
|
|
|
7
9
|
self
|
|
8
10
|
end
|
|
9
11
|
|
|
12
|
+
# @return [Time, nil] the times the app asked for.
|
|
10
13
|
def scheduled_at = Jbr.mock.job[:scheduled_at]
|
|
11
14
|
|
|
12
15
|
def completed_at = Jbr.mock.job[:completed_at]
|
data/lib/jbr/mock/oauth.rb
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
+
# Credentials that answer from {Jbr.mock} instead of Jobber.
|
|
2
3
|
class Mock::OAuth < OAuth
|
|
4
|
+
# The mocked resources these credentials read and write.
|
|
3
5
|
def invoices = Mock::Invoice.new(oauth: self)
|
|
4
6
|
def jobs = Mock::Job.new(oauth: self)
|
|
5
7
|
def quotes = Mock::Quote.new(oauth: self)
|
|
6
8
|
def requests = Mock::Request.new(oauth: self)
|
|
7
9
|
def account = Mock::Account.new oauth: self
|
|
8
10
|
|
|
11
|
+
# Revoking a mocked token asks nobody.
|
|
9
12
|
def delete; end
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
# @return [Hash] canned credentials, unless the app asked for a refusal.
|
|
13
15
|
def self.post(_)
|
|
14
16
|
raise Error, Jbr.mock.oauth_error if Jbr.mock.oauth_error
|
|
15
17
|
|
|
16
|
-
{ access_token: 'mock-token', refresh_token: 'mock-token', expires_at: (Time.
|
|
18
|
+
{ access_token: 'mock-token', refresh_token: 'mock-token', expires_at: (Time.now + 3600) }
|
|
17
19
|
end
|
|
18
20
|
end
|
|
19
21
|
end
|
data/lib/jbr/mock/quote.rb
CHANGED
data/lib/jbr/mock/request.rb
CHANGED
data/lib/jbr/mock/url.rb
CHANGED
data/lib/jbr/mock.rb
CHANGED
data/lib/jbr/mocking.rb
CHANGED
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
+
# The switch an app under test throws to answer Jobber without a network. Touching
|
|
3
|
+
# {#mock} once turns every entry point below over to its Mock counterpart.
|
|
2
4
|
module Mocking
|
|
5
|
+
# @return [Mock] the answers this process gives, created on first use.
|
|
3
6
|
def mock
|
|
4
7
|
@mock ||= Jbr::Mock.new
|
|
5
8
|
end
|
|
6
9
|
|
|
10
|
+
# @param params [Hash] the +code+ and +redirect_uri+ to exchange.
|
|
11
|
+
# @return [OAuth] credentials for the account that authorized the app.
|
|
7
12
|
def create_oauth(params = {})
|
|
8
|
-
(@mock ? Mock::OAuth : OAuth).create
|
|
13
|
+
(@mock ? Mock::OAuth : OAuth).create(**params)
|
|
9
14
|
end
|
|
10
15
|
|
|
16
|
+
# @param params [Hash] the +redirect_uri+ and +state+ to come back with.
|
|
17
|
+
# @return [String] the URL a Jobber user authorizes the app on.
|
|
11
18
|
def oauth_url_for(params = {})
|
|
12
|
-
(@mock ? Mock::URL : URL).for
|
|
19
|
+
(@mock ? Mock::URL : URL).for(**params)
|
|
13
20
|
end
|
|
14
21
|
|
|
22
|
+
# @param params [Hash] credentials already on file.
|
|
23
|
+
# @return [OAuth] those credentials, ready to read and write with.
|
|
15
24
|
def oauth_for(params = {})
|
|
16
|
-
(@mock ? Mock::OAuth : OAuth).new
|
|
25
|
+
(@mock ? Mock::OAuth : OAuth).new(params)
|
|
17
26
|
end
|
|
18
27
|
end
|
|
19
28
|
|
|
20
29
|
extend Mocking
|
|
21
|
-
end
|
|
30
|
+
end
|
data/lib/jbr/oauth.rb
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
+
# Credentials for one Jobber account, and the gateway to everything read or written
|
|
3
|
+
# with them. An expired access token is refreshed and the call retried once.
|
|
2
4
|
class OAuth
|
|
3
|
-
|
|
5
|
+
# The mutation that revokes the app on the account.
|
|
6
|
+
DISCONNECT_MUTATION = <<~GRAPHQL
|
|
4
7
|
mutation Disconnect {
|
|
5
8
|
appDisconnect {
|
|
6
9
|
app { name author }
|
|
@@ -9,6 +12,7 @@ module Jbr
|
|
|
9
12
|
}
|
|
10
13
|
GRAPHQL
|
|
11
14
|
|
|
15
|
+
# @param credentials [Hash] the tokens, their expiry, the account and when it went bad.
|
|
12
16
|
def initialize(credentials = {})
|
|
13
17
|
@access_token = credentials[:access_token]
|
|
14
18
|
@refresh_token = credentials[:refresh_token]
|
|
@@ -17,9 +21,12 @@ module Jbr
|
|
|
17
21
|
@invalid_at = credentials[:invalid_at]
|
|
18
22
|
end
|
|
19
23
|
|
|
24
|
+
# The credentials as Jobber last gave them, plus the moment a refusal to refresh landed.
|
|
20
25
|
attr_reader :access_token, :refresh_token, :expires_at, :invalid_at
|
|
26
|
+
# @return [String, nil] the account these credentials reach.
|
|
21
27
|
attr_accessor :account_id
|
|
22
28
|
|
|
29
|
+
# The resources these credentials read and write.
|
|
23
30
|
def account = Account.new oauth: self
|
|
24
31
|
def clients = Client.new oauth: self
|
|
25
32
|
def invoices = Invoice.new oauth: self
|
|
@@ -27,9 +34,13 @@ module Jbr
|
|
|
27
34
|
def quotes = Quote.new oauth: self
|
|
28
35
|
def requests = Request.new oauth: self
|
|
29
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.
|
|
40
|
+
# @return [Hash] the data Jobber answered, or empty when the credentials are dead.
|
|
30
41
|
def query(statement, variables: {})
|
|
31
42
|
client.query statement, variables: variables
|
|
32
|
-
rescue GraphQL::Unauthorized
|
|
43
|
+
rescue GraphQL::Unauthorized
|
|
33
44
|
refresh ? retry : {}
|
|
34
45
|
end
|
|
35
46
|
|
|
@@ -39,6 +50,10 @@ module Jbr
|
|
|
39
50
|
rescue GraphQL::Unauthorized => e
|
|
40
51
|
end
|
|
41
52
|
|
|
53
|
+
# Exchange an authorization code for credentials, then learn their account.
|
|
54
|
+
# @param code [String] the code Jobber sent to the redirect URI.
|
|
55
|
+
# @param redirect_uri [String] the URI the code came back to.
|
|
56
|
+
# @return [OAuth] the new credentials.
|
|
42
57
|
def self.create(code:, redirect_uri:)
|
|
43
58
|
credentials = post code: code, redirect_uri: redirect_uri, grant_type: 'authorization_code'
|
|
44
59
|
new(credentials).tap { |oauth| oauth.account_id = oauth.account.id }
|
|
@@ -50,6 +65,19 @@ module Jbr
|
|
|
50
65
|
# @return [String, nil] The client secret to interact with the API.
|
|
51
66
|
def self.client_secret = ENV['JOBBER_CLIENT_SECRET']
|
|
52
67
|
|
|
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.
|
|
70
|
+
def self.post(params = {})
|
|
71
|
+
uri = URI 'https://api.getjobber.com/api/oauth/token'
|
|
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
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
53
81
|
private
|
|
54
82
|
|
|
55
83
|
def refresh
|
|
@@ -58,21 +86,13 @@ module Jbr
|
|
|
58
86
|
@refresh_token = output[:refresh_token]
|
|
59
87
|
@expires_at = output[:expires_at]
|
|
60
88
|
rescue Error => e
|
|
61
|
-
@invalid_at = Time.
|
|
89
|
+
@invalid_at = Time.now
|
|
62
90
|
false
|
|
63
91
|
end
|
|
64
92
|
|
|
65
|
-
def self.post(params = {})
|
|
66
|
-
uri = URI 'https://api.getjobber.com/api/oauth/token'
|
|
67
|
-
response = Net::HTTP.post_form uri, params.merge(client_id: client_id, client_secret: client_secret)
|
|
68
|
-
raise Error, response.body unless response.is_a? Net::HTTPSuccess
|
|
69
|
-
output = JSON.parse(response.body)
|
|
70
|
-
{ access_token: output['access_token'], refresh_token: output['refresh_token'],
|
|
71
|
-
expires_at: (Time.current + output.fetch('expires_in', 3600).to_i) }
|
|
72
|
-
end
|
|
73
|
-
|
|
74
93
|
def client
|
|
75
|
-
GraphQL::Client.new endpoint: 'https://api.getjobber.com/api/graphql',
|
|
94
|
+
GraphQL::Client.new endpoint: 'https://api.getjobber.com/api/graphql',
|
|
95
|
+
token: @access_token, headers: headers
|
|
76
96
|
end
|
|
77
97
|
|
|
78
98
|
def headers = { 'X-JOBBER-GRAPHQL-VERSION' => '2026-04-22' }
|
data/lib/jbr/property.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# Where the work happens: one address on a client's file.
|
|
3
|
+
class Property < Resource
|
|
4
|
+
# The mutation that adds a property to a client already on file.
|
|
5
|
+
CREATE = <<~GRAPHQL
|
|
6
|
+
mutation propertyCreateMutation($clientId: EncodedId!, $input: PropertyCreateInput!) {
|
|
7
|
+
propertyCreate(clientId: $clientId, input: $input) {
|
|
8
|
+
properties { id }
|
|
9
|
+
userErrors { message }
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
GRAPHQL
|
|
13
|
+
|
|
14
|
+
# What Jobber calls each address field, against what a caller passes.
|
|
15
|
+
FIELDS = { street1: :street, city: :city, province: :state, postalCode: :zip }
|
|
16
|
+
|
|
17
|
+
# The fields a match is made on. City and state are written but never matched:
|
|
18
|
+
# Jobber holds whatever was typed, so "NC" and "North Carolina" -- or "Winston Salem"
|
|
19
|
+
# and "Winston-Salem" -- would read as two homes. The ZIP already places the home.
|
|
20
|
+
MATCHED = %i[street1 postalCode]
|
|
21
|
+
|
|
22
|
+
# The address as Jobber takes it, from the fields a caller passes.
|
|
23
|
+
# @param fields [Hash] any of :street, :city, :state and :zip.
|
|
24
|
+
# @return [Hash] the address, without the fields the caller left out.
|
|
25
|
+
def self.address_from(fields = {})
|
|
26
|
+
FIELDS.to_h { |jobber, ours| [ jobber, fields[ours] ] }.compact
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Reach the property at an address, adding one when none of the client's matches.
|
|
30
|
+
# @param client_id [String] the client the property belongs to.
|
|
31
|
+
# @param address [Hash] the fields the work happens at.
|
|
32
|
+
# @param existing [Array<Hash>] the properties already on the client's file.
|
|
33
|
+
# @return [String, nil] the property ID.
|
|
34
|
+
def find_or_create_for(client_id:, address:, existing: [])
|
|
35
|
+
wanted = self.class.address_from address
|
|
36
|
+
match = existing.find { |property| same_address? wanted, property['address'] }
|
|
37
|
+
return match['id'] if match
|
|
38
|
+
|
|
39
|
+
output = @oauth.query CREATE, variables: {
|
|
40
|
+
clientId: client_id, input: { properties: [ { address: wanted } ] },
|
|
41
|
+
}
|
|
42
|
+
(output&.dig('propertyCreate', 'properties')&.first || {})['id']
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Field by field, because Jobber answers every field it was asked for, nil included,
|
|
48
|
+
# while a caller's address carries only what they had -- an absent field and a nil
|
|
49
|
+
# one are the same address. All of {MATCHED} has to agree: a home with no street
|
|
50
|
+
# parsed must not match the one house on the client's file that does have one.
|
|
51
|
+
def same_address?(wanted, address)
|
|
52
|
+
MATCHED.all? { |field| wanted[field] == (address || {})[field.to_s] }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/jbr/quote.rb
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
+
# A price a Jobber user sent to their client.
|
|
2
3
|
class Quote < Resource
|
|
3
|
-
|
|
4
|
+
# The query that reads one quote and the request it came from.
|
|
5
|
+
FIND = <<~GRAPHQL
|
|
4
6
|
query($id: EncodedId!) {
|
|
5
7
|
quote(id: $id) { id request { id } }
|
|
6
8
|
}
|
|
7
9
|
GRAPHQL
|
|
8
10
|
|
|
11
|
+
# @return [String, nil] the ID of the request the quote answers.
|
|
9
12
|
attr_reader :request_id
|
|
10
13
|
|
|
14
|
+
# @param id [String] the Jobber ID of the quote.
|
|
15
|
+
# @return [Quote, nil] itself, or nil when Jobber has no such quote.
|
|
11
16
|
def find(id)
|
|
12
17
|
output = @oauth.query FIND, variables: { id: id }
|
|
13
18
|
return unless quote = output['quote']
|
data/lib/jbr/request.rb
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
+
# A lead on a Jobber user's board: work someone asked them for.
|
|
2
3
|
class Request < Resource
|
|
3
|
-
|
|
4
|
+
# The mutation that opens a request against a client and a property.
|
|
5
|
+
CREATE = <<~GRAPHQL
|
|
4
6
|
mutation($input: RequestCreateInput!) {
|
|
5
7
|
requestCreate(input: $input) { request { id property { id } } userErrors { message } }
|
|
6
8
|
}
|
|
7
9
|
GRAPHQL
|
|
8
10
|
|
|
11
|
+
# @return [String, nil] the client the request was opened against.
|
|
9
12
|
attr_reader :client_id
|
|
10
13
|
|
|
11
14
|
# Create a lead in Jobber associated to a new or existing client, matched by phone.
|
|
@@ -24,7 +27,7 @@ module Jbr
|
|
|
24
27
|
|
|
25
28
|
input = {
|
|
26
29
|
clientId: @client_id, title: params[:title], propertyId: @property_id,
|
|
27
|
-
assessment: { instructions: params[:instructions] }
|
|
30
|
+
assessment: { instructions: params[:instructions] },
|
|
28
31
|
}
|
|
29
32
|
output = @oauth.query CREATE, variables: { input: input }
|
|
30
33
|
@id = output.dig 'requestCreate', 'request', 'id'
|
data/lib/jbr/resource.rb
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
+
# What every Jobber resource shares: the credentials it is read or written through.
|
|
2
3
|
class Resource
|
|
4
|
+
# @param oauth [OAuth] the credentials to reach Jobber with.
|
|
3
5
|
def initialize(oauth:)
|
|
4
6
|
@oauth = oauth
|
|
5
7
|
end
|
|
6
8
|
|
|
9
|
+
# @return [String, nil] the Jobber ID, once the resource has been read or created.
|
|
7
10
|
attr_reader :id
|
|
8
11
|
end
|
|
9
12
|
end
|
data/lib/jbr/url.rb
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
+
# The page a Jobber user authorizes the app on.
|
|
2
3
|
class URL
|
|
4
|
+
# @param params [Hash] the +redirect_uri+ and +state+ to come back with.
|
|
5
|
+
# @return [String] the URL to send the user to.
|
|
3
6
|
def self.for(params = {})
|
|
4
7
|
uri = URI 'https://api.getjobber.com/api/oauth/authorize'
|
|
5
|
-
uri.query
|
|
8
|
+
uri.query = URI.encode_www_form params.merge(response_type: 'code', client_id: client_id)
|
|
6
9
|
uri.to_s
|
|
7
10
|
end
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
# @return [String, nil] the client ID of the app being authorized.
|
|
11
13
|
def self.client_id = ENV['JOBBER_CLIENT_ID']
|
|
14
|
+
private_class_method :client_id
|
|
12
15
|
end
|
|
13
16
|
end
|
data/lib/jbr/version.rb
CHANGED
data/lib/jbr.rb
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
require 'json'
|
|
4
2
|
require 'net/http'
|
|
5
3
|
|
|
@@ -19,6 +17,7 @@ require 'jbr/account'
|
|
|
19
17
|
require 'jbr/client'
|
|
20
18
|
require 'jbr/invoice'
|
|
21
19
|
require 'jbr/job'
|
|
20
|
+
require 'jbr/property'
|
|
22
21
|
require 'jbr/quote'
|
|
23
22
|
|
|
24
23
|
require 'jbr/mock/oauth'
|
metadata
CHANGED
|
@@ -1,14 +1,84 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jbr
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Claudio Baccigalupo
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
-
dependencies:
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rubocop-rails-omakase
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: simplecov
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: webmock
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0'
|
|
12
82
|
description: Jobber API
|
|
13
83
|
email:
|
|
14
84
|
- claudiob@users.noreply.github.com
|
|
@@ -16,6 +86,7 @@ executables: []
|
|
|
16
86
|
extensions: []
|
|
17
87
|
extra_rdoc_files: []
|
|
18
88
|
files:
|
|
89
|
+
- ".rubocop.yml"
|
|
19
90
|
- CHANGELOG.md
|
|
20
91
|
- LICENSE.txt
|
|
21
92
|
- README.md
|
|
@@ -25,7 +96,6 @@ files:
|
|
|
25
96
|
- lib/jbr.rb
|
|
26
97
|
- lib/jbr/account.rb
|
|
27
98
|
- lib/jbr/client.rb
|
|
28
|
-
- lib/jbr/configuration.rb
|
|
29
99
|
- lib/jbr/error.rb
|
|
30
100
|
- lib/jbr/event.rb
|
|
31
101
|
- lib/jbr/invoice.rb
|
|
@@ -40,6 +110,7 @@ files:
|
|
|
40
110
|
- lib/jbr/mock/url.rb
|
|
41
111
|
- lib/jbr/mocking.rb
|
|
42
112
|
- lib/jbr/oauth.rb
|
|
113
|
+
- lib/jbr/property.rb
|
|
43
114
|
- lib/jbr/quote.rb
|
|
44
115
|
- lib/jbr/request.rb
|
|
45
116
|
- lib/jbr/resource.rb
|