lucid_shopify 0.5.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +102 -0
- data/lib/lucid_shopify.rb +26 -0
- data/lib/lucid_shopify/activate_charge.rb +28 -0
- data/lib/lucid_shopify/charge.rb +53 -0
- data/lib/lucid_shopify/client.rb +49 -0
- data/lib/lucid_shopify/create_charge.rb +28 -0
- data/lib/lucid_shopify/credentials.rb +42 -0
- data/lib/lucid_shopify/delegate_webhooks.rb +43 -0
- data/lib/lucid_shopify/delete_request.rb +15 -0
- data/lib/lucid_shopify/fetch_access_token.rb +50 -0
- data/lib/lucid_shopify/get_request.rb +16 -0
- data/lib/lucid_shopify/post_request.rb +16 -0
- data/lib/lucid_shopify/put_request.rb +16 -0
- data/lib/lucid_shopify/request.rb +50 -0
- data/lib/lucid_shopify/request_credentials.rb +14 -0
- data/lib/lucid_shopify/response.rb +113 -0
- data/lib/lucid_shopify/result.rb +33 -0
- data/lib/lucid_shopify/send_request.rb +64 -0
- data/lib/lucid_shopify/send_throttled_request.rb +50 -0
- data/lib/lucid_shopify/verify_callback.rb +70 -0
- data/lib/lucid_shopify/verify_webhook.rb +40 -0
- data/lib/lucid_shopify/version.rb +5 -0
- data/lib/lucid_shopify/webhook.rb +28 -0
- data/lib/lucid_shopify/webhooks.rb +82 -0
- metadata +123 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: e4631f8c0d8631d94e42e0213c32c9e349c2f86648fd0816acb98595f8891b98
|
4
|
+
data.tar.gz: ce3b5019de9bd7b89bb20a01c6bc61ad23f5ea04290f752fdd8ac6121ff637fa
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 793fed51a3be7594e48d4a6f2190d8651a30846e50bf13691e547618aa82358bb91b4824d47ca7987df02a15896fef864350db61e3e3c4ad616d920eb4400db0
|
7
|
+
data.tar.gz: bab3fdf7ae40219c614db5c76436392468d92ec766ccdf06773c31572a0b20995ccff7c5b8701cea5a366f22883e188294d1cadb9405ebaaf13f194fa3cd1dc2
|
data/README.md
ADDED
@@ -0,0 +1,102 @@
|
|
1
|
+
lucid_shopify
|
2
|
+
=============
|
3
|
+
|
4
|
+
Installation
|
5
|
+
------------
|
6
|
+
|
7
|
+
Add the following lines to your ‘Gemfile’:
|
8
|
+
|
9
|
+
git_source :lucid { |r| "https://github.com/lucidnz/gem-lucid_#{r}.git" }
|
10
|
+
|
11
|
+
gem 'lucid_shopify', lucid: 'shopify'
|
12
|
+
|
13
|
+
|
14
|
+
Usage
|
15
|
+
-----
|
16
|
+
|
17
|
+
### Configure the default API client credentials
|
18
|
+
|
19
|
+
LucidShopify.credentials = LucidShopify::Credentials.new(
|
20
|
+
'...', # api_key
|
21
|
+
'...', # shared_secret
|
22
|
+
'...', # scope
|
23
|
+
'...', # billing_callback_uri
|
24
|
+
'...', # webhook_uri
|
25
|
+
)
|
26
|
+
|
27
|
+
Alternatively, a credentials object may be passed as a keyword
|
28
|
+
argument to any of the classes that make use of it.
|
29
|
+
|
30
|
+
Additionally, each API request requires authorization:
|
31
|
+
|
32
|
+
request_credentials = LucidShopify::RequestCredentials.new(
|
33
|
+
'...', # myshopify_domain
|
34
|
+
'...', # access_token
|
35
|
+
)
|
36
|
+
|
37
|
+
If the access token is omitted, the request will be unauthorized.
|
38
|
+
This is only useful during the OAuth2 process.
|
39
|
+
|
40
|
+
|
41
|
+
### Configure webhooks
|
42
|
+
|
43
|
+
Configure each webhook the app will create (if any):
|
44
|
+
|
45
|
+
LucidShopify.webhooks << {topic: 'orders/create', fields: %w(id tags)}
|
46
|
+
LucidShopify.webhooks << {topic: '...', fields: %w(...)}
|
47
|
+
|
48
|
+
|
49
|
+
### Register webhook handlers
|
50
|
+
|
51
|
+
For each webhook, register one or more handlers:
|
52
|
+
|
53
|
+
delegate_webhooks = LucidShopify::DelegateWebhooks.default
|
54
|
+
|
55
|
+
delegate_webhooks.register('orders/create', OrdersCreateWebhook.new)
|
56
|
+
|
57
|
+
See the inline method documentation for more detail.
|
58
|
+
|
59
|
+
To call/delegate a webhook to its handler for processing, you will likely want
|
60
|
+
to create a worker around something like this:
|
61
|
+
|
62
|
+
webhook = LucidShopify::Webhook.new(myshopify_domain, topic, data)
|
63
|
+
|
64
|
+
delegate_webhooks.(webhook)
|
65
|
+
|
66
|
+
|
67
|
+
### Create and delete webhooks
|
68
|
+
|
69
|
+
Create/delete all configured webhooks (see above):
|
70
|
+
|
71
|
+
webhooks = LucidShopify::Webhooks.new
|
72
|
+
|
73
|
+
webhooks.create_all(request_credentials)
|
74
|
+
webhooks.delete_all(request_credentials)
|
75
|
+
|
76
|
+
Create/delete webhooks manually:
|
77
|
+
|
78
|
+
webhook = {topic: 'orders/create', fields: %w(id tags)}
|
79
|
+
|
80
|
+
webhooks.create(request_credentials, webhook)
|
81
|
+
webhooks.delete(request_credentials, webhook_id)
|
82
|
+
|
83
|
+
|
84
|
+
### Verification
|
85
|
+
|
86
|
+
Verify callback requests with the request params:
|
87
|
+
|
88
|
+
LucidShopify::Verify::Callback.new.(params_hash).success?
|
89
|
+
|
90
|
+
Verify webhook requests with the request data and the HMAC header:
|
91
|
+
|
92
|
+
LucidShopify::Verify::Webhook.new.(data, hmac).success?
|
93
|
+
|
94
|
+
|
95
|
+
### Authorization
|
96
|
+
|
97
|
+
_TODO_
|
98
|
+
|
99
|
+
|
100
|
+
### Make an API request
|
101
|
+
|
102
|
+
_TODO_
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Primarily for Bundler.
|
4
|
+
|
5
|
+
require 'lucid_shopify/activate_charge'
|
6
|
+
require 'lucid_shopify/charge'
|
7
|
+
require 'lucid_shopify/client'
|
8
|
+
require 'lucid_shopify/create_charge'
|
9
|
+
require 'lucid_shopify/credentials'
|
10
|
+
require 'lucid_shopify/delegate_webhooks'
|
11
|
+
require 'lucid_shopify/delete_request'
|
12
|
+
require 'lucid_shopify/fetch_access_token'
|
13
|
+
require 'lucid_shopify/get_request'
|
14
|
+
require 'lucid_shopify/post_request'
|
15
|
+
require 'lucid_shopify/put_request'
|
16
|
+
require 'lucid_shopify/request_credentials'
|
17
|
+
require 'lucid_shopify/request'
|
18
|
+
require 'lucid_shopify/response'
|
19
|
+
require 'lucid_shopify/result'
|
20
|
+
require 'lucid_shopify/send_request'
|
21
|
+
require 'lucid_shopify/send_throttled_request'
|
22
|
+
require 'lucid_shopify/verify_callback'
|
23
|
+
require 'lucid_shopify/verify_webhook'
|
24
|
+
require 'lucid_shopify/version'
|
25
|
+
require 'lucid_shopify/webhook'
|
26
|
+
require 'lucid_shopify/webhooks'
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dry-initializer'
|
4
|
+
|
5
|
+
require 'lucid_shopify/client'
|
6
|
+
|
7
|
+
module LucidShopify
|
8
|
+
class ActivateCharge
|
9
|
+
extend Dry::Initializer
|
10
|
+
|
11
|
+
# @return [Client]
|
12
|
+
option :client, default: proc { Client.new }
|
13
|
+
|
14
|
+
#
|
15
|
+
# Activate a recurring application charge.
|
16
|
+
#
|
17
|
+
# @param request_credentials [RequestCredentials]
|
18
|
+
# @param charge [Hash, #to_h] an accepted charge received from Shopify via callback
|
19
|
+
#
|
20
|
+
# @return [Hash] the active charge
|
21
|
+
#
|
22
|
+
def call(request_credentials, charge)
|
23
|
+
data = client.post_json(request_credentials, "recurring_application_charges/#{charge_id}/activate", charge.to_h)
|
24
|
+
|
25
|
+
data['recurring_application_charge']
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dry-initializer'
|
4
|
+
|
5
|
+
require 'lucid_shopify/credentials'
|
6
|
+
|
7
|
+
module LucidShopify
|
8
|
+
#
|
9
|
+
# Provides a convenient way to build the charge hash for {CreateCharge}.
|
10
|
+
#
|
11
|
+
class Charge
|
12
|
+
extend Dry::Initializer
|
13
|
+
|
14
|
+
# @return [String]
|
15
|
+
param :plan_name
|
16
|
+
# @return [Integer]
|
17
|
+
param :price
|
18
|
+
# @return [Integer] requires price_terms
|
19
|
+
option :price_cap, optional: true
|
20
|
+
# @return [String] requires price_cap
|
21
|
+
option :price_terms, optional: true
|
22
|
+
# @return [Boolean] is this a test charge?
|
23
|
+
option :test, default: proc { false }
|
24
|
+
# @return [Integer]
|
25
|
+
option :trial_days, default: proc { 7 }
|
26
|
+
# @return [Credentials]
|
27
|
+
option :credentials, default: proc { LucidShopify.credentials }
|
28
|
+
|
29
|
+
#
|
30
|
+
# Map to the Shopify API structure.
|
31
|
+
#
|
32
|
+
# @return [Hash]
|
33
|
+
#
|
34
|
+
def to_h
|
35
|
+
{}.tap do |hash|
|
36
|
+
hash[:name] = plan_name
|
37
|
+
hash[:price] = price
|
38
|
+
hash[:capped_amount] = price_cap if usage_based_billing?
|
39
|
+
hash[:terms] = price_terms if usage_based_billing?
|
40
|
+
hash[:return_url] = credentials.billing_callback_uri
|
41
|
+
hash[:test] = test if test
|
42
|
+
hash[:trial_days] = trial_days if trial_days
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
#
|
47
|
+
# @return [Boolean]
|
48
|
+
#
|
49
|
+
private def usage_based_billing?
|
50
|
+
price_cap && price_terms
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'lucid_shopify/send_request'
|
4
|
+
|
5
|
+
%w(delete get post put).each do |method|
|
6
|
+
require "lucid_shopify/#{method}_request"
|
7
|
+
end
|
8
|
+
|
9
|
+
module LucidShopify
|
10
|
+
class Client
|
11
|
+
#
|
12
|
+
# @param send_request [SendRequest]
|
13
|
+
#
|
14
|
+
def initialize(send_request: SendRequest.new)
|
15
|
+
@send_request = send_request
|
16
|
+
end
|
17
|
+
|
18
|
+
# @return [SendRequest]
|
19
|
+
attr_reader :send_request
|
20
|
+
|
21
|
+
#
|
22
|
+
# @see {DeleteRequest#initialize}
|
23
|
+
#
|
24
|
+
def delete(*args)
|
25
|
+
send_request.(DeleteRequest.new(*args))
|
26
|
+
end
|
27
|
+
|
28
|
+
#
|
29
|
+
# @see {GetRequest#initialize}
|
30
|
+
#
|
31
|
+
def get(*args)
|
32
|
+
send_request.(GetRequest.new(*args))
|
33
|
+
end
|
34
|
+
|
35
|
+
#
|
36
|
+
# @see {PostRequest#initialize}
|
37
|
+
#
|
38
|
+
def post_json(*args)
|
39
|
+
send_request.(PostRequest.new(*args))
|
40
|
+
end
|
41
|
+
|
42
|
+
#
|
43
|
+
# @see {PutRequest#initialize}
|
44
|
+
#
|
45
|
+
def put_json(*args)
|
46
|
+
send_request.(PutRequest.new(*args))
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dry-initializer'
|
4
|
+
|
5
|
+
require 'lucid_shopify/client'
|
6
|
+
|
7
|
+
module LucidShopify
|
8
|
+
class CreateCharge
|
9
|
+
extend Dry::Initializer
|
10
|
+
|
11
|
+
# @return [Client]
|
12
|
+
option :client, default: proc { Client.new }
|
13
|
+
|
14
|
+
#
|
15
|
+
# Create a new recurring application charge.
|
16
|
+
#
|
17
|
+
# @param request_credentials [RequestCredentials]
|
18
|
+
# @param charge [Hash, #to_h]
|
19
|
+
#
|
20
|
+
# @return [Hash] the pending charge
|
21
|
+
#
|
22
|
+
def call(request_credentials, charge)
|
23
|
+
data = client.post_json(request_credentials, 'recurring_application_charge', charge.to_h)
|
24
|
+
|
25
|
+
data['recurring_application_charge']
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dry-initializer'
|
4
|
+
|
5
|
+
module LucidShopify
|
6
|
+
MissingCredentialsError = Class.new(StandardError)
|
7
|
+
|
8
|
+
class Credentials
|
9
|
+
extend Dry::Initializer
|
10
|
+
|
11
|
+
# @return [String]
|
12
|
+
param :api_key
|
13
|
+
# @return [String]
|
14
|
+
param :shared_secret
|
15
|
+
# @return [String]
|
16
|
+
param :scope
|
17
|
+
# @return [String]
|
18
|
+
param :billing_callback_uri
|
19
|
+
# @return [String]
|
20
|
+
param :webhook_uri
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
class << LucidShopify
|
25
|
+
#
|
26
|
+
# Assign default API credentials.
|
27
|
+
#
|
28
|
+
# @param credentials [LucidShopify::Credentials]
|
29
|
+
#
|
30
|
+
attr_writer :credentials
|
31
|
+
|
32
|
+
#
|
33
|
+
# @return [LucidShopify::Credentials]
|
34
|
+
#
|
35
|
+
# @raise [LucidShopify::MissingCredentialsError] if credentials are unset
|
36
|
+
#
|
37
|
+
def credentials
|
38
|
+
raise MissingCredentialsError unless @credentials
|
39
|
+
|
40
|
+
@credentials
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LucidShopify
|
4
|
+
class DelegateWebhooks
|
5
|
+
class << self
|
6
|
+
#
|
7
|
+
# @return [DelegateWebhooks]
|
8
|
+
#
|
9
|
+
def default
|
10
|
+
@default ||= new
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
@handlers = {}
|
16
|
+
end
|
17
|
+
|
18
|
+
# @return [Hash<String, Array<#call>>]
|
19
|
+
attr_reader :handlers
|
20
|
+
|
21
|
+
#
|
22
|
+
# Call each of the handlers registered for the given topic in turn. See
|
23
|
+
# {#register} below for more on webhook handlers.
|
24
|
+
#
|
25
|
+
# @param webhook [LucidShopify::Webhook]
|
26
|
+
#
|
27
|
+
def call(webhook)
|
28
|
+
handlers[webhook.topic]&.each { |handler| handler.(webhook) }
|
29
|
+
end
|
30
|
+
|
31
|
+
#
|
32
|
+
# Register a handler for a webhook topic. The callable handler will be
|
33
|
+
# called with the argument passed to {#call}.
|
34
|
+
#
|
35
|
+
# @param topic [String] e.g. 'orders/create'
|
36
|
+
# @param handler [#call]
|
37
|
+
#
|
38
|
+
def register(topic, handler)
|
39
|
+
handlers[topic] ||= []
|
40
|
+
handlers[topic] << handler
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'lucid_shopify/request'
|
4
|
+
|
5
|
+
module LucidShopify
|
6
|
+
class DeleteRequest < Request
|
7
|
+
#
|
8
|
+
# @param credentials [RequestCredentials]
|
9
|
+
# @param path [String] the endpoint relative to the base URL
|
10
|
+
#
|
11
|
+
def initialize(credentials, path)
|
12
|
+
super(credentials, :delete, path)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dry-initializer'
|
4
|
+
|
5
|
+
require 'lucid_shopify/client'
|
6
|
+
|
7
|
+
module LucidShopify
|
8
|
+
class FetchAccessToken
|
9
|
+
Error = Class.new(StandardError)
|
10
|
+
|
11
|
+
extend Dry::Initializer
|
12
|
+
|
13
|
+
# @return [Client]
|
14
|
+
option :client, default: proc { Client.new }
|
15
|
+
# @return [Credentials]
|
16
|
+
option :credentials, default: proc { LucidShopify.credentials }
|
17
|
+
|
18
|
+
#
|
19
|
+
# Exchange an authorization code for a new Shopify access token.
|
20
|
+
#
|
21
|
+
# @param request_credentials [RequestCredentials]
|
22
|
+
# @param authorization_code [String]
|
23
|
+
#
|
24
|
+
# @return [String] the access token
|
25
|
+
#
|
26
|
+
# @raise [Error] if the response is invalid
|
27
|
+
#
|
28
|
+
def call(request_credentials, authorization_code)
|
29
|
+
data = client.post_json(request_credentials, 'oauth/access_token', post_data(authorization_code))
|
30
|
+
|
31
|
+
raise Error if data['access_token'].nil?
|
32
|
+
raise Error if data['scope'] != credentials.scope
|
33
|
+
|
34
|
+
data['access_token']
|
35
|
+
end
|
36
|
+
|
37
|
+
#
|
38
|
+
# @param authorization_code [String]
|
39
|
+
#
|
40
|
+
# @return [Hash]
|
41
|
+
#
|
42
|
+
private def post_data(authorization_code)
|
43
|
+
{
|
44
|
+
client_id: credentials.api_key,
|
45
|
+
client_secret: credentials.shared_secret,
|
46
|
+
code: authorization_code,
|
47
|
+
}
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'lucid_shopify/request'
|
4
|
+
|
5
|
+
module LucidShopify
|
6
|
+
class GetRequest < Request
|
7
|
+
#
|
8
|
+
# @param credentials [RequestCredentials]
|
9
|
+
# @param path [String] the endpoint relative to the base URL
|
10
|
+
# @param params [Hash] the query params
|
11
|
+
#
|
12
|
+
def initialize(credentials, path, params = {})
|
13
|
+
super(credentials, :get, path, params: params)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'lucid_shopify/request'
|
4
|
+
|
5
|
+
module LucidShopify
|
6
|
+
class PostRequest < Request
|
7
|
+
#
|
8
|
+
# @param credentials [RequestCredentials]
|
9
|
+
# @param path [String] the endpoint relative to the base URL
|
10
|
+
# @param json [Hash] the JSON request body
|
11
|
+
#
|
12
|
+
def initialize(credentials, path, json)
|
13
|
+
super(credentials, :post, path, json: json)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'lucid_shopify/request'
|
4
|
+
|
5
|
+
module LucidShopify
|
6
|
+
class PutRequest < Request
|
7
|
+
#
|
8
|
+
# @param credentials [RequestCredentials]
|
9
|
+
# @param path [String] the endpoint relative to the base URL
|
10
|
+
# @param json [Hash] the JSON request body
|
11
|
+
#
|
12
|
+
def initialize(credentials, path, json)
|
13
|
+
super(credentials, :put, path, json: json)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dry-initializer'
|
4
|
+
|
5
|
+
module LucidShopify
|
6
|
+
#
|
7
|
+
# @abstract
|
8
|
+
#
|
9
|
+
class Request
|
10
|
+
extend Dry::Initializer
|
11
|
+
|
12
|
+
# @return [RequestCredentials]
|
13
|
+
param :credentials
|
14
|
+
# @return [Symbol]
|
15
|
+
param :http_method
|
16
|
+
# @return [String] the endpoint relative to the base URL
|
17
|
+
param :path, reader: :private
|
18
|
+
# @return [Hash]
|
19
|
+
param :options, default: proc { {} }
|
20
|
+
|
21
|
+
# @return [Hash]
|
22
|
+
param :http_headers, default: proc { build_headers }
|
23
|
+
# @return [String]
|
24
|
+
param :url, default: proc { build_url }
|
25
|
+
|
26
|
+
#
|
27
|
+
# @return [String]
|
28
|
+
#
|
29
|
+
private def build_url
|
30
|
+
admin_url = "https://#{credentials.myshopify_domain}/admin"
|
31
|
+
|
32
|
+
path = path.sub(/^\//, '')
|
33
|
+
path = path.sub(/\.json$/, '')
|
34
|
+
|
35
|
+
admin_url + '/' + path + '.json'
|
36
|
+
end
|
37
|
+
|
38
|
+
#
|
39
|
+
# @return [Hash]
|
40
|
+
#
|
41
|
+
private def build_headers
|
42
|
+
access_token = credentials.access_token
|
43
|
+
|
44
|
+
{}.tap do |headers|
|
45
|
+
headers['Accept'] = 'application/json'
|
46
|
+
headers['X-Shopify-Access-token'] = access_token if access_token
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dry-initializer'
|
4
|
+
|
5
|
+
module LucidShopify
|
6
|
+
class RequestCredentials
|
7
|
+
extend Dry::Initializer
|
8
|
+
|
9
|
+
# @return [String]
|
10
|
+
param :myshopify_domain
|
11
|
+
# @return [String, nil] if {nil}, request will be unauthorized
|
12
|
+
param :access_token, optional: true
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,113 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dry-initializer'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
module LucidShopify
|
7
|
+
class Response
|
8
|
+
#
|
9
|
+
# @abstract
|
10
|
+
#
|
11
|
+
class Error < StandardError
|
12
|
+
extend Dry::Initializer
|
13
|
+
|
14
|
+
# @return [Request]
|
15
|
+
param :request
|
16
|
+
# @return [Response]
|
17
|
+
param :response
|
18
|
+
|
19
|
+
#
|
20
|
+
# @return [String]
|
21
|
+
#
|
22
|
+
def message
|
23
|
+
"bad response (#{response.status_code})"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
ClientError = Class.new(Error)
|
28
|
+
ServerError = Class.new(Error)
|
29
|
+
|
30
|
+
extend Dry::Initializer
|
31
|
+
|
32
|
+
# @return [Request] the original request
|
33
|
+
param :request
|
34
|
+
# @return [Integer]
|
35
|
+
param :status_code
|
36
|
+
# @return [Hash]
|
37
|
+
param :headers
|
38
|
+
# @return [String]
|
39
|
+
param :data
|
40
|
+
# @return [Hash] the parsed response body
|
41
|
+
param :data_hash, default: proc { parse_data }
|
42
|
+
|
43
|
+
#
|
44
|
+
# @return [Hash]
|
45
|
+
#
|
46
|
+
private def parse_data
|
47
|
+
return {} unless json?
|
48
|
+
|
49
|
+
JSON.parse(data)
|
50
|
+
end
|
51
|
+
# private def parse_data(data)
|
52
|
+
# JSON.parse(data)
|
53
|
+
# rescue JSON::ParserError
|
54
|
+
# {}
|
55
|
+
# end
|
56
|
+
|
57
|
+
#
|
58
|
+
# @return [Boolean]
|
59
|
+
#
|
60
|
+
private def json?
|
61
|
+
headers['Content-Type'] =~ /application\/json/
|
62
|
+
end
|
63
|
+
|
64
|
+
#
|
65
|
+
# @return [String]
|
66
|
+
#
|
67
|
+
# @see {#assert!}
|
68
|
+
#
|
69
|
+
def data!
|
70
|
+
assert!.data
|
71
|
+
end
|
72
|
+
|
73
|
+
#
|
74
|
+
# @return [Hash] the parsed response body
|
75
|
+
#
|
76
|
+
# @see {#assert!}
|
77
|
+
#
|
78
|
+
def data_hash!
|
79
|
+
assert!.data_hash
|
80
|
+
end
|
81
|
+
|
82
|
+
#
|
83
|
+
# @raise [ClientError] for status 4xx
|
84
|
+
# @raise [ServerError] for status 5xx
|
85
|
+
#
|
86
|
+
# @return [self]
|
87
|
+
#
|
88
|
+
def assert!
|
89
|
+
case status_code
|
90
|
+
when 400..499
|
91
|
+
raise ClientError.new(request, self)
|
92
|
+
when 500..599
|
93
|
+
raise ServerError.new(request, self)
|
94
|
+
end
|
95
|
+
|
96
|
+
self
|
97
|
+
end
|
98
|
+
|
99
|
+
#
|
100
|
+
# @return [Boolean]
|
101
|
+
#
|
102
|
+
def success?
|
103
|
+
status_code.between?(200, 299)
|
104
|
+
end
|
105
|
+
|
106
|
+
#
|
107
|
+
# @return [Boolean]
|
108
|
+
#
|
109
|
+
def failure?
|
110
|
+
!success?
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LucidShopify
|
4
|
+
class Result
|
5
|
+
#
|
6
|
+
# @param value [Object]
|
7
|
+
# @param error [Object]
|
8
|
+
#
|
9
|
+
def initialize(value, error = nil)
|
10
|
+
@value = value
|
11
|
+
@error = error
|
12
|
+
end
|
13
|
+
|
14
|
+
# @return [Object]
|
15
|
+
attr_reader :value
|
16
|
+
# @return [Object]
|
17
|
+
attr_reader :error
|
18
|
+
|
19
|
+
#
|
20
|
+
# @return [Boolean]
|
21
|
+
#
|
22
|
+
def success?
|
23
|
+
error.nil?
|
24
|
+
end
|
25
|
+
|
26
|
+
#
|
27
|
+
# @return [Boolean]
|
28
|
+
#
|
29
|
+
def failure?
|
30
|
+
!success?
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dry-initializer'
|
4
|
+
require 'http'
|
5
|
+
|
6
|
+
require 'lucid_shopify/response'
|
7
|
+
|
8
|
+
module LucidShopify
|
9
|
+
class SendRequest
|
10
|
+
class NetworkError < StandardError
|
11
|
+
extend Dry::Initializer
|
12
|
+
|
13
|
+
# @return [HTTP::Error]
|
14
|
+
param :original_exception
|
15
|
+
end
|
16
|
+
|
17
|
+
#
|
18
|
+
# @param request [Request]
|
19
|
+
# @param attempts [Integer] additional request attempts on client error
|
20
|
+
#
|
21
|
+
# @return [Hash] the parsed response body
|
22
|
+
#
|
23
|
+
# @raise [NetworkError] if the request failed all attempts
|
24
|
+
# @raise [Response::ClientError] for status 4xx
|
25
|
+
# @raise [Response::ServerError] for status 5xx
|
26
|
+
#
|
27
|
+
def call(request, attempts = default_attempts)
|
28
|
+
req = request
|
29
|
+
res = send(req.http_method, req.url, req.options)
|
30
|
+
res = Response.new(req, res.code, res.headers.to_h, res.to_s)
|
31
|
+
|
32
|
+
res.data_hash!
|
33
|
+
rescue *http_network_errors => e
|
34
|
+
raise NetworkError.new(e), e.message if attempts.zero?
|
35
|
+
|
36
|
+
call(request, attempts - 1)
|
37
|
+
end
|
38
|
+
|
39
|
+
#
|
40
|
+
# @return [HTTP::Response]
|
41
|
+
#
|
42
|
+
private def send(http_method, url, options)
|
43
|
+
HTTP.headers(request.headers).__send__(http_method, url, options)
|
44
|
+
end
|
45
|
+
|
46
|
+
#
|
47
|
+
# @return [Integer]
|
48
|
+
#
|
49
|
+
private def default_attempts
|
50
|
+
3
|
51
|
+
end
|
52
|
+
|
53
|
+
#
|
54
|
+
# @return [Array<Class>]
|
55
|
+
#
|
56
|
+
private def http_network_errors
|
57
|
+
[
|
58
|
+
HTTP::ConnectionError,
|
59
|
+
HTTP::ResponseError,
|
60
|
+
HTTP::TimeoutError,
|
61
|
+
]
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'lucid_shopify/send_request'
|
4
|
+
|
5
|
+
module LucidShopify
|
6
|
+
class SendThrottledRequest < SendRequest
|
7
|
+
MINIMUM_INTERVAL = 500 # ms
|
8
|
+
|
9
|
+
#
|
10
|
+
# @see {SendRequest#call}
|
11
|
+
#
|
12
|
+
private def call(*)
|
13
|
+
interval
|
14
|
+
|
15
|
+
super
|
16
|
+
end
|
17
|
+
|
18
|
+
#
|
19
|
+
# Sleep for the difference if time since the last request is less than the
|
20
|
+
# MINIMUM_INTERVAL.
|
21
|
+
#
|
22
|
+
# @note Throttling is only maintained across a single thread.
|
23
|
+
#
|
24
|
+
private def interval
|
25
|
+
if Thread.current[interval_key]
|
26
|
+
(timestamp - Thread.current[interval_key]).tap do |n|
|
27
|
+
sleep(Rational(n, 1000)) if n < MINIMUM_INTERVAL
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
Thread.current[interval_key] = timestamp
|
32
|
+
end
|
33
|
+
|
34
|
+
#
|
35
|
+
# @return [String]
|
36
|
+
#
|
37
|
+
private def interval_key
|
38
|
+
'%s[%s].timestamp' % [self.class, request.credentials.myshopify_domain]
|
39
|
+
end
|
40
|
+
|
41
|
+
#
|
42
|
+
# Time in milliseconds since the UNIX epoch.
|
43
|
+
#
|
44
|
+
# @return [Integer]
|
45
|
+
#
|
46
|
+
private def timestamp
|
47
|
+
(Time.now.to_f * 1000).to_i
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'openssl'
|
4
|
+
|
5
|
+
require 'lucid_shopify/credentials'
|
6
|
+
require 'lucid_shopify/result'
|
7
|
+
|
8
|
+
module LucidShopify
|
9
|
+
class VerifyCallback
|
10
|
+
#
|
11
|
+
# @param credentials [LucidShopify::Credentials]
|
12
|
+
#
|
13
|
+
def initialize(credentials: LucidShopify.credentials)
|
14
|
+
@credentials = credentials
|
15
|
+
end
|
16
|
+
|
17
|
+
# @return [LucidShopify::Credentials]
|
18
|
+
attr_reader :credentials
|
19
|
+
|
20
|
+
#
|
21
|
+
# Verify that the callback request originated from Shopify.
|
22
|
+
#
|
23
|
+
# @param params_hash [Hash] the request params
|
24
|
+
#
|
25
|
+
# @return [Result]
|
26
|
+
#
|
27
|
+
def call(params_hash)
|
28
|
+
digest = OpenSSL::Digest::SHA256.new
|
29
|
+
digest = OpenSSL::HMAC.hexdigest(digest, credentials.shared_secret, encoded_params(params_hash))
|
30
|
+
result = digest == params_hash[:hmac]
|
31
|
+
|
32
|
+
Result.new(result, result ? nil : 'invalid request')
|
33
|
+
end
|
34
|
+
|
35
|
+
#
|
36
|
+
# @param params_hash [Hash]
|
37
|
+
#
|
38
|
+
# @return [String]
|
39
|
+
#
|
40
|
+
private def encoded_params(params_hash)
|
41
|
+
params_hash.reject do |k, _|
|
42
|
+
k == :hmac
|
43
|
+
end.map do |k, v|
|
44
|
+
encode_key(k) + '=' + encode_value(v)
|
45
|
+
end.join('&')
|
46
|
+
end
|
47
|
+
|
48
|
+
#
|
49
|
+
# @param k [String, Symbol]
|
50
|
+
#
|
51
|
+
# @return [String]
|
52
|
+
#
|
53
|
+
private def encode_key(k)
|
54
|
+
k.to_s.gsub(/./) do |chr|
|
55
|
+
{'%' => '%25', '&' => '%26', '=' => '%3D'}[chr] || chr
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
#
|
60
|
+
# @param v [String]
|
61
|
+
#
|
62
|
+
# @return [String]
|
63
|
+
#
|
64
|
+
private def encode_value(v)
|
65
|
+
v.gsub(/./) do |chr|
|
66
|
+
{'%' => '%25', '&' => '%26'}[chr] || chr
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'base64'
|
4
|
+
require 'openssl'
|
5
|
+
|
6
|
+
require 'lucid_shopify/credentials'
|
7
|
+
require 'lucid_shopify/result'
|
8
|
+
|
9
|
+
module LucidShopify
|
10
|
+
class VerifyWebhook
|
11
|
+
Error = Class.new(StandardError)
|
12
|
+
|
13
|
+
#
|
14
|
+
# @param credentials [LucidShopify::Credentials]
|
15
|
+
#
|
16
|
+
def initialize(credentials: LucidShopify.credentials)
|
17
|
+
@credentials = credentials
|
18
|
+
end
|
19
|
+
|
20
|
+
# @return [LucidShopify::Credentials]
|
21
|
+
attr_reader :credentials
|
22
|
+
|
23
|
+
#
|
24
|
+
# Verify that the webhook request originated from Shopify.
|
25
|
+
#
|
26
|
+
# @param data [String] the signed request data
|
27
|
+
# @param hmac [String] the signature
|
28
|
+
#
|
29
|
+
# @return [Result]
|
30
|
+
#
|
31
|
+
def call(data, hmac)
|
32
|
+
digest = OpenSSL::Digest::SHA256.new
|
33
|
+
digest = OpenSSL::HMAC.digest(digest, credentials.shared_secret, data)
|
34
|
+
digest = Base64.encode64(digest).strip
|
35
|
+
result = digest == hmac
|
36
|
+
|
37
|
+
Result.new(result, result ? nil : 'invalid request')
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dry-initializer'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
module LucidShopify
|
7
|
+
class Webhook
|
8
|
+
extend Dry::Initializer
|
9
|
+
|
10
|
+
# @return [String]
|
11
|
+
param :myshopify_domain
|
12
|
+
# @return [String]
|
13
|
+
param :topic
|
14
|
+
# @return [String]
|
15
|
+
param :data
|
16
|
+
# @return [Hash] the parsed request body
|
17
|
+
param :data_hash, default: proc { parse_data }
|
18
|
+
|
19
|
+
#
|
20
|
+
# @return [Hash]
|
21
|
+
#
|
22
|
+
private def parse_data
|
23
|
+
JSON.parse(data)
|
24
|
+
rescue JSON::ParserError
|
25
|
+
{}
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dry-initializer'
|
4
|
+
|
5
|
+
require 'lucid_shopify/client'
|
6
|
+
require 'lucid_shopify/credentials'
|
7
|
+
|
8
|
+
module LucidShopify
|
9
|
+
class Webhooks
|
10
|
+
extend Dry::Initializer
|
11
|
+
|
12
|
+
# @return [Client]
|
13
|
+
option :client, default: proc { Client.new }
|
14
|
+
# @return [Credentials]
|
15
|
+
option :credentials, default: proc { LucidShopify.credentials }
|
16
|
+
|
17
|
+
#
|
18
|
+
# Delete any existing webhooks, then (re)create all webhooks for the shop.
|
19
|
+
#
|
20
|
+
# @param request_credentials [RequestCredentials]
|
21
|
+
#
|
22
|
+
def create_all(request_credentials)
|
23
|
+
delete_all
|
24
|
+
|
25
|
+
LucidShopify.webhooks.map do |webhook|
|
26
|
+
Thread.new { create(request_credentials, webhook) }
|
27
|
+
end.map(&:value)
|
28
|
+
end
|
29
|
+
|
30
|
+
#
|
31
|
+
# Create a webhook.
|
32
|
+
#
|
33
|
+
# @param request_credentials [RequestCredentials]
|
34
|
+
# @param webhook [Hash]
|
35
|
+
#
|
36
|
+
def create(request_credentials, webhook)
|
37
|
+
data = {}
|
38
|
+
data[:address] = credentials.webhook_uri
|
39
|
+
data[:fields] = webhook[:fields] if webhook[:fields]
|
40
|
+
data[:topic] = webhook[:topic]
|
41
|
+
|
42
|
+
client.post_json(request_credentials, 'webhooks', webhook: data)
|
43
|
+
end
|
44
|
+
|
45
|
+
#
|
46
|
+
# Delete any existing webhooks.
|
47
|
+
#
|
48
|
+
# @param request_credentials [RequestCredentials]
|
49
|
+
#
|
50
|
+
def delete_all(request_credentials)
|
51
|
+
webhooks = client.get('webhooks')['webhooks']
|
52
|
+
|
53
|
+
webhooks.map do |webhook|
|
54
|
+
Thread.new { delete(request_credentials, webhook['id']) }
|
55
|
+
end.map(&:value)
|
56
|
+
end
|
57
|
+
|
58
|
+
#
|
59
|
+
# Delete a webhook.
|
60
|
+
#
|
61
|
+
# @param request_credentials [RequestCredentials]
|
62
|
+
# @param id [Integer]
|
63
|
+
#
|
64
|
+
def delete(request_credentials, id)
|
65
|
+
client.delete(request_credentials, "webhooks/#{id}")
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
class << LucidShopify
|
71
|
+
#
|
72
|
+
# Webhooks created for each shop.
|
73
|
+
#
|
74
|
+
# @return [Array<Hash>]
|
75
|
+
#
|
76
|
+
# @example
|
77
|
+
# LucidShopify.webhooks << {topic: 'orders/create', fields: %w(id)}
|
78
|
+
#
|
79
|
+
def webhooks
|
80
|
+
@webhooks ||= []
|
81
|
+
end
|
82
|
+
end
|
metadata
ADDED
@@ -0,0 +1,123 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: lucid_shopify
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.5.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Kelsey Judson
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2018-03-02 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rspec
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.6'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3.6'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rubocop
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 0.52.0
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 0.52.0
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: dry-initializer
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '2.4'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '2.4'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: http
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '3.0'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '3.0'
|
69
|
+
description:
|
70
|
+
email: kelsey@lucid.nz
|
71
|
+
executables: []
|
72
|
+
extensions: []
|
73
|
+
extra_rdoc_files: []
|
74
|
+
files:
|
75
|
+
- README.md
|
76
|
+
- lib/lucid_shopify.rb
|
77
|
+
- lib/lucid_shopify/activate_charge.rb
|
78
|
+
- lib/lucid_shopify/charge.rb
|
79
|
+
- lib/lucid_shopify/client.rb
|
80
|
+
- lib/lucid_shopify/create_charge.rb
|
81
|
+
- lib/lucid_shopify/credentials.rb
|
82
|
+
- lib/lucid_shopify/delegate_webhooks.rb
|
83
|
+
- lib/lucid_shopify/delete_request.rb
|
84
|
+
- lib/lucid_shopify/fetch_access_token.rb
|
85
|
+
- lib/lucid_shopify/get_request.rb
|
86
|
+
- lib/lucid_shopify/post_request.rb
|
87
|
+
- lib/lucid_shopify/put_request.rb
|
88
|
+
- lib/lucid_shopify/request.rb
|
89
|
+
- lib/lucid_shopify/request_credentials.rb
|
90
|
+
- lib/lucid_shopify/response.rb
|
91
|
+
- lib/lucid_shopify/result.rb
|
92
|
+
- lib/lucid_shopify/send_request.rb
|
93
|
+
- lib/lucid_shopify/send_throttled_request.rb
|
94
|
+
- lib/lucid_shopify/verify_callback.rb
|
95
|
+
- lib/lucid_shopify/verify_webhook.rb
|
96
|
+
- lib/lucid_shopify/version.rb
|
97
|
+
- lib/lucid_shopify/webhook.rb
|
98
|
+
- lib/lucid_shopify/webhooks.rb
|
99
|
+
homepage: https://github.com/lucidnz/gem-lucid_shopify
|
100
|
+
licenses:
|
101
|
+
- ISC
|
102
|
+
metadata: {}
|
103
|
+
post_install_message:
|
104
|
+
rdoc_options: []
|
105
|
+
require_paths:
|
106
|
+
- lib
|
107
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
108
|
+
requirements:
|
109
|
+
- - ">="
|
110
|
+
- !ruby/object:Gem::Version
|
111
|
+
version: '0'
|
112
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
113
|
+
requirements:
|
114
|
+
- - ">="
|
115
|
+
- !ruby/object:Gem::Version
|
116
|
+
version: '0'
|
117
|
+
requirements: []
|
118
|
+
rubyforge_project:
|
119
|
+
rubygems_version: 2.7.3
|
120
|
+
signing_key:
|
121
|
+
specification_version: 4
|
122
|
+
summary: Shopify client library
|
123
|
+
test_files: []
|