lucid_shopify 0.5.1

Sign up to get free protection for your applications and to get access to all the features.
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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LucidShopify
4
+ VERSION = '0.5.1'
5
+ 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: []