lucid_shopify 0.5.1

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 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: []