shopify-client 0.0.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 +342 -0
- data/lib/shopify-client.rb +49 -0
- data/lib/shopify-client/authorise.rb +38 -0
- data/lib/shopify-client/bulk_request.rb +219 -0
- data/lib/shopify-client/cache/redis_store.rb +28 -0
- data/lib/shopify-client/cache/store.rb +67 -0
- data/lib/shopify-client/cache/thread_local_store.rb +47 -0
- data/lib/shopify-client/cached_request.rb +69 -0
- data/lib/shopify-client/client.rb +126 -0
- data/lib/shopify-client/client/logging.rb +38 -0
- data/lib/shopify-client/client/normalise_path.rb +14 -0
- data/lib/shopify-client/cookieless/check_header.rb +28 -0
- data/lib/shopify-client/cookieless/decode_session_token.rb +43 -0
- data/lib/shopify-client/cookieless/middleware.rb +39 -0
- data/lib/shopify-client/create_all_webhooks.rb +23 -0
- data/lib/shopify-client/create_webhook.rb +21 -0
- data/lib/shopify-client/delete_all_webhooks.rb +22 -0
- data/lib/shopify-client/delete_webhook.rb +13 -0
- data/lib/shopify-client/error.rb +9 -0
- data/lib/shopify-client/parse_link_header.rb +33 -0
- data/lib/shopify-client/request.rb +40 -0
- data/lib/shopify-client/resource/base.rb +46 -0
- data/lib/shopify-client/resource/create.rb +31 -0
- data/lib/shopify-client/resource/delete.rb +29 -0
- data/lib/shopify-client/resource/read.rb +80 -0
- data/lib/shopify-client/resource/update.rb +30 -0
- data/lib/shopify-client/response.rb +201 -0
- data/lib/shopify-client/response_errors.rb +59 -0
- data/lib/shopify-client/response_user_errors.rb +42 -0
- data/lib/shopify-client/struct.rb +10 -0
- data/lib/shopify-client/throttling/redis_strategy.rb +62 -0
- data/lib/shopify-client/throttling/strategy.rb +50 -0
- data/lib/shopify-client/throttling/thread_local_strategy.rb +29 -0
- data/lib/shopify-client/verify_request.rb +51 -0
- data/lib/shopify-client/verify_webhook.rb +24 -0
- data/lib/shopify-client/version.rb +5 -0
- data/lib/shopify-client/webhook.rb +32 -0
- data/lib/shopify-client/webhook_list.rb +50 -0
- metadata +206 -0
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ShopifyClient
|
4
|
+
module Cache
|
5
|
+
class RedisStore < Store
|
6
|
+
# @see Store#get
|
7
|
+
def get(key)
|
8
|
+
value = Redis.current.get(key)
|
9
|
+
|
10
|
+
@decode.(value) unless value.nil?
|
11
|
+
end
|
12
|
+
|
13
|
+
# @see Store#set
|
14
|
+
def set(key, value, ttl: ShopifyClient.config.cache_ttl)
|
15
|
+
Redis.current.set(key, @encode.(value))
|
16
|
+
|
17
|
+
if ttl > 0
|
18
|
+
Redis.current.expire(key, ttl)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# @see Store#clear
|
23
|
+
def clear(key)
|
24
|
+
Redis.current.del(key)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module ShopifyClient
|
6
|
+
module Cache
|
7
|
+
# @abstract
|
8
|
+
class Store
|
9
|
+
# @param encode [#call]
|
10
|
+
# @param decode [#call]
|
11
|
+
#
|
12
|
+
# @example
|
13
|
+
# store = Store.new(
|
14
|
+
# encode: MessagePack.method(:pack),
|
15
|
+
# decode: MessagePack.method(:unpack),
|
16
|
+
# )
|
17
|
+
def initialize(encode: JSON.method(:generate), decode: JSON.method(:parse))
|
18
|
+
@encode = encode
|
19
|
+
@decode = decode
|
20
|
+
end
|
21
|
+
|
22
|
+
# Fetch a value from the cache, falling back to the result of the given
|
23
|
+
# block when the cache is empty/expired.
|
24
|
+
#
|
25
|
+
# @param key [String]
|
26
|
+
# @param ttl [Integer] in seconds
|
27
|
+
#
|
28
|
+
# @return [Object]
|
29
|
+
def call(key, ttl: ShopifyClient.config.cache_ttl, &block)
|
30
|
+
value = get(key)
|
31
|
+
|
32
|
+
if value.nil?
|
33
|
+
value = block.()
|
34
|
+
|
35
|
+
set(key, value, ttl: ttl)
|
36
|
+
end
|
37
|
+
|
38
|
+
value
|
39
|
+
end
|
40
|
+
|
41
|
+
# Get cached data, or nil if unset.
|
42
|
+
#
|
43
|
+
# @param key [String]
|
44
|
+
#
|
45
|
+
# @return [Object]
|
46
|
+
private def get(key)
|
47
|
+
raise NotImplementedError
|
48
|
+
end
|
49
|
+
|
50
|
+
# Overwrite cached data and set TTL (if implemented by child class).
|
51
|
+
#
|
52
|
+
# @param key [String]
|
53
|
+
# @param value [Object]
|
54
|
+
# @param ttl [Integer] in seconds
|
55
|
+
def set(key, value, ttl: ShopifyClient.config.cache_ttl)
|
56
|
+
raise NotImplementedError
|
57
|
+
end
|
58
|
+
|
59
|
+
# Clear cached data.
|
60
|
+
#
|
61
|
+
# @param key [String]
|
62
|
+
def clear(key)
|
63
|
+
raise NotImplementedError
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ShopifyClient
|
4
|
+
module Cache
|
5
|
+
class ThreadLocalStore < Store
|
6
|
+
# @see Store#get
|
7
|
+
def get(key)
|
8
|
+
return nil if expired?(key)
|
9
|
+
|
10
|
+
value = Thread.current[key]
|
11
|
+
|
12
|
+
@decode.(value) unless value.nil?
|
13
|
+
end
|
14
|
+
|
15
|
+
# @see Store#set
|
16
|
+
def set(key, value, ttl: ShopifyClient.config.cache_ttl)
|
17
|
+
Thread.current[key] = @encode.(value)
|
18
|
+
|
19
|
+
if ttl > 0
|
20
|
+
Thread.current[build_expiry_key(key)] = Time.now + ttl
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# @see Store#clear
|
25
|
+
def clear(key)
|
26
|
+
Thread.current[key] = nil
|
27
|
+
Thread.current[build_expiry_key(key)] = nil
|
28
|
+
end
|
29
|
+
|
30
|
+
# @param key [String]
|
31
|
+
#
|
32
|
+
# @return [Boolean]
|
33
|
+
private def expired?(key)
|
34
|
+
expires_at = Thread.current[build_expiry_key(key)]
|
35
|
+
|
36
|
+
expires_at.nil? ? false : Time.now > expires_at
|
37
|
+
end
|
38
|
+
|
39
|
+
# @param key [String]
|
40
|
+
#
|
41
|
+
# @return [String]
|
42
|
+
private def build_expiry_key(key)
|
43
|
+
"#{key}:expires_at"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'digest'
|
4
|
+
|
5
|
+
module ShopifyClient
|
6
|
+
# Caching for GET requests.
|
7
|
+
#
|
8
|
+
# @example
|
9
|
+
# get_shop = CachedRequest.new('shop', fields: 'domain,plan')
|
10
|
+
# get_shop.(client) # not cached, makes API request
|
11
|
+
# get_shop.(client) # cached
|
12
|
+
class CachedRequest
|
13
|
+
# @param path [String]
|
14
|
+
# @param params [Hash]
|
15
|
+
# @param store [Cache::Store]
|
16
|
+
def initialize(path, params: {}, store: default_store)
|
17
|
+
@path = path
|
18
|
+
@params = params
|
19
|
+
@store = store
|
20
|
+
end
|
21
|
+
|
22
|
+
# @return [Cache::Store]
|
23
|
+
def default_store
|
24
|
+
if defined?(Redis)
|
25
|
+
Cache::RedisStore.new
|
26
|
+
else
|
27
|
+
Cache::ThreadLocalStore.new
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# @param client [Client]
|
32
|
+
#
|
33
|
+
# @return [Hash] response data
|
34
|
+
def call(client)
|
35
|
+
@store.(build_key(client.myshopify_domain)) do
|
36
|
+
client.get(@path, @params).data
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# @param myshopify_domain [String]
|
41
|
+
#
|
42
|
+
# @return [String]
|
43
|
+
private def build_key(myshopify_domain)
|
44
|
+
separator = "\x1f" # ASCII unit separator
|
45
|
+
|
46
|
+
format('shopify-client:cached_request:%s', Digest::SHA256.hexdigest([
|
47
|
+
myshopify_domain,
|
48
|
+
@path,
|
49
|
+
@params.sort,
|
50
|
+
].join(separator)))
|
51
|
+
end
|
52
|
+
|
53
|
+
# Overwrite cached data for a given shop. This might be used when the data
|
54
|
+
# is received from a webhook.
|
55
|
+
#
|
56
|
+
# @param myshopify_domain [String]
|
57
|
+
# @param data [Hash]
|
58
|
+
def set(myshopify_domain, data)
|
59
|
+
@store.set(build_key(myshopify_domain), data)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Clear the cached data for a given shop.
|
63
|
+
#
|
64
|
+
# @param myshopify_domain [String]
|
65
|
+
def clear(myshopify_domain)
|
66
|
+
@store.clear(build_key(myshopify_domain))
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,126 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'faraday'
|
4
|
+
require 'faraday_middleware'
|
5
|
+
|
6
|
+
module ShopifyClient
|
7
|
+
# @!attribute [r] myshopify_domain
|
8
|
+
# @return [String]
|
9
|
+
# @!attribute [r] access_token
|
10
|
+
# @return [String]
|
11
|
+
class Client
|
12
|
+
# @param myshopify_domain [String]
|
13
|
+
# @param access_token [String, nil] if request is authenticated
|
14
|
+
def initialize(myshopify_domain, access_token = nil)
|
15
|
+
@conn = Faraday.new(
|
16
|
+
headers: {
|
17
|
+
'X-Shopify-Access-Token' => access_token,
|
18
|
+
},
|
19
|
+
url: "https://#{myshopify_domain}/admin/api/#{ShopifyClient.config.api_version}",
|
20
|
+
) do |conn|
|
21
|
+
# Request throttling to avoid API rate limit.
|
22
|
+
conn.use default_throttling_strategy
|
23
|
+
# Retry for 429, too many requests.
|
24
|
+
conn.use Faraday::Request::Retry, {
|
25
|
+
backoff_factor: 2,
|
26
|
+
interval: 0.5,
|
27
|
+
retry_statuses: [429],
|
28
|
+
}
|
29
|
+
# Retry for 5xx, server errors.
|
30
|
+
conn.use Faraday::Request::Retry, {
|
31
|
+
exceptions: [
|
32
|
+
Faraday::ConnectionFailed,
|
33
|
+
Faraday::RetriableResponse,
|
34
|
+
Faraday::ServerError,
|
35
|
+
Faraday::SSLError,
|
36
|
+
Faraday::TimeoutError,
|
37
|
+
],
|
38
|
+
backoff_factor: 2,
|
39
|
+
interval: 0.5,
|
40
|
+
retry_statuses: (500..599).to_a,
|
41
|
+
}
|
42
|
+
conn.use FaradayMiddleware::EncodeJson
|
43
|
+
conn.use FaradayMiddleware::ParseJson, content_type: 'application/json'
|
44
|
+
# Add .json suffix if not present (all endpoints use this).
|
45
|
+
conn.use NormalisePath
|
46
|
+
conn.use Logging
|
47
|
+
end
|
48
|
+
|
49
|
+
@myshopify_domain = myshopify_domain
|
50
|
+
@access_token = access_token
|
51
|
+
end
|
52
|
+
|
53
|
+
# @return [Throttling::Strategy]
|
54
|
+
def default_throttling_strategy
|
55
|
+
if defined?(Redis)
|
56
|
+
Throttling::RedisStrategy
|
57
|
+
else
|
58
|
+
Throttling::ThreadLocalStrategy
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
attr_reader :myshopify_domain
|
63
|
+
attr_reader :access_token
|
64
|
+
|
65
|
+
# @see Faraday::Connection#delete
|
66
|
+
#
|
67
|
+
# @return [Response]
|
68
|
+
def delete(...)
|
69
|
+
Response.from_faraday_response(@conn.delete(...), self)
|
70
|
+
end
|
71
|
+
|
72
|
+
# @see Faraday::Connection#get
|
73
|
+
#
|
74
|
+
# @return [Response]
|
75
|
+
def get(...)
|
76
|
+
Response.from_faraday_response(@conn.get(...), self)
|
77
|
+
end
|
78
|
+
|
79
|
+
# @see CachedRequest#initialize
|
80
|
+
def get_cached(...)
|
81
|
+
CachedRequest.new(...).(self)
|
82
|
+
end
|
83
|
+
|
84
|
+
# @see Faraday::Connection#post
|
85
|
+
#
|
86
|
+
# @return [Response]
|
87
|
+
def post(...)
|
88
|
+
Response.from_faraday_response(@conn.post(...), self)
|
89
|
+
end
|
90
|
+
|
91
|
+
# @see Faraday::Connection#put
|
92
|
+
#
|
93
|
+
# @return [Response]
|
94
|
+
def put(...)
|
95
|
+
Response.from_faraday_response(@conn.put(...), self)
|
96
|
+
end
|
97
|
+
|
98
|
+
# @param query [String] the GraphQL query
|
99
|
+
# @param variables [Hash] the GraphQL variables (if any)
|
100
|
+
#
|
101
|
+
# @return [Response]
|
102
|
+
def graphql(query, variables = {})
|
103
|
+
Response.from_faraday_response(@conn.post('graphql', {
|
104
|
+
query: query,
|
105
|
+
variables: variables,
|
106
|
+
}), self)
|
107
|
+
end
|
108
|
+
|
109
|
+
# If called with a block, calls {BulkRequest::Operation#call} immediately,
|
110
|
+
# else, returns the {BulkRequest::Operation}.
|
111
|
+
#
|
112
|
+
# @param query [String] the GraphQL query
|
113
|
+
#
|
114
|
+
# @return [Operation]
|
115
|
+
def graphql_bulk(query, &block)
|
116
|
+
op = BulkRequest.new.(self, query)
|
117
|
+
|
118
|
+
block ? op.(&block) : op
|
119
|
+
end
|
120
|
+
|
121
|
+
# @return [String]
|
122
|
+
def inspect
|
123
|
+
"#<ShopifyClient::Client (#{@myshopify_domain})>"
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'securerandom'
|
5
|
+
|
6
|
+
module ShopifyClient
|
7
|
+
class Client
|
8
|
+
class Logging < Faraday::Middleware
|
9
|
+
# @param env [Faraday::Env]
|
10
|
+
def on_request(env)
|
11
|
+
env[:uuid] = SecureRandom.uuid
|
12
|
+
|
13
|
+
ShopifyClient.config.logger.info({
|
14
|
+
source: 'shopify-client',
|
15
|
+
type: 'request',
|
16
|
+
info: {
|
17
|
+
transaction_id: env[:uuid],
|
18
|
+
method: env[:method].to_s,
|
19
|
+
url: env[:url].to_s,
|
20
|
+
},
|
21
|
+
}.to_json)
|
22
|
+
end
|
23
|
+
|
24
|
+
# @param env [Faraday::Env]
|
25
|
+
def on_complete(env)
|
26
|
+
ShopifyClient.config.logger.info({
|
27
|
+
source: 'shopify-client',
|
28
|
+
type: 'response',
|
29
|
+
info: {
|
30
|
+
transaction_id: env[:uuid],
|
31
|
+
status: env[:status],
|
32
|
+
api_call_limit: env[:response_headers]['X-Shopify-Shop-Api-Call-Limit'],
|
33
|
+
},
|
34
|
+
}.to_json)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ShopifyClient
|
4
|
+
class Client
|
5
|
+
class NormalisePath < Faraday::Middleware
|
6
|
+
# @param env [Faraday::Env]
|
7
|
+
def on_request(env)
|
8
|
+
unless env[:url].path.end_with?('.json')
|
9
|
+
env[:url].path += '.json'
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module ShopifyClient
|
6
|
+
module Cookieless
|
7
|
+
class CheckHeader
|
8
|
+
UnauthorisedError = Class.new(Error)
|
9
|
+
|
10
|
+
# @param rack_env [Hash]
|
11
|
+
#
|
12
|
+
# @raise [UnauthorisedError]
|
13
|
+
def call(rack_env)
|
14
|
+
header = rack_env['HTTP_AUTHORIZATION']
|
15
|
+
|
16
|
+
raise UnauthorisedError, 'missing Authorization header' if header.nil?
|
17
|
+
|
18
|
+
session_token = header.[](/Bearer (\S+)/, 1)
|
19
|
+
|
20
|
+
raise UnauthorisedError, 'invalid Authorization header' if session_token.nil?
|
21
|
+
|
22
|
+
rack_env['shopify-client.shop'] = DecodeSessionToken.new.(session_token)
|
23
|
+
rescue DecodeSessionToken::Error
|
24
|
+
raise UnauthorisedError, 'invalid session token'
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'jwt'
|
4
|
+
|
5
|
+
module ShopifyClient
|
6
|
+
module Cookieless
|
7
|
+
class DecodeSessionToken
|
8
|
+
Error = Class.new(Error)
|
9
|
+
|
10
|
+
# @param token [String]
|
11
|
+
#
|
12
|
+
# @return [String] the *.myshopify.com domain of the authenticated shop
|
13
|
+
#
|
14
|
+
# @raise [Error]
|
15
|
+
def call(token)
|
16
|
+
payload, _ = JWT.decode(token, ShopifyClient.config.shared_secret, true, algorithm: 'HS256')
|
17
|
+
|
18
|
+
raise Error unless valid?(payload)
|
19
|
+
|
20
|
+
parse_myshopify_domain(payload)
|
21
|
+
rescue JWT::DecodeError
|
22
|
+
raise Error
|
23
|
+
end
|
24
|
+
|
25
|
+
# @param payload [Hash]
|
26
|
+
#
|
27
|
+
# @return [String]
|
28
|
+
private def parse_myshopify_domain(payload)
|
29
|
+
payload['dest'].sub('https://', '')
|
30
|
+
end
|
31
|
+
|
32
|
+
# @param payload [Hash]
|
33
|
+
#
|
34
|
+
# @return [Boolean]
|
35
|
+
private def valid?(payload)
|
36
|
+
return false unless payload['aud'] == ShopifyClient.config.api_key
|
37
|
+
return false unless payload['iss'].start_with?(payload['dest'])
|
38
|
+
|
39
|
+
true
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|