shopify-client 0.0.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 +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
|