shopify-client 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +342 -0
  3. data/lib/shopify-client.rb +49 -0
  4. data/lib/shopify-client/authorise.rb +38 -0
  5. data/lib/shopify-client/bulk_request.rb +219 -0
  6. data/lib/shopify-client/cache/redis_store.rb +28 -0
  7. data/lib/shopify-client/cache/store.rb +67 -0
  8. data/lib/shopify-client/cache/thread_local_store.rb +47 -0
  9. data/lib/shopify-client/cached_request.rb +69 -0
  10. data/lib/shopify-client/client.rb +126 -0
  11. data/lib/shopify-client/client/logging.rb +38 -0
  12. data/lib/shopify-client/client/normalise_path.rb +14 -0
  13. data/lib/shopify-client/cookieless/check_header.rb +28 -0
  14. data/lib/shopify-client/cookieless/decode_session_token.rb +43 -0
  15. data/lib/shopify-client/cookieless/middleware.rb +39 -0
  16. data/lib/shopify-client/create_all_webhooks.rb +23 -0
  17. data/lib/shopify-client/create_webhook.rb +21 -0
  18. data/lib/shopify-client/delete_all_webhooks.rb +22 -0
  19. data/lib/shopify-client/delete_webhook.rb +13 -0
  20. data/lib/shopify-client/error.rb +9 -0
  21. data/lib/shopify-client/parse_link_header.rb +33 -0
  22. data/lib/shopify-client/request.rb +40 -0
  23. data/lib/shopify-client/resource/base.rb +46 -0
  24. data/lib/shopify-client/resource/create.rb +31 -0
  25. data/lib/shopify-client/resource/delete.rb +29 -0
  26. data/lib/shopify-client/resource/read.rb +80 -0
  27. data/lib/shopify-client/resource/update.rb +30 -0
  28. data/lib/shopify-client/response.rb +201 -0
  29. data/lib/shopify-client/response_errors.rb +59 -0
  30. data/lib/shopify-client/response_user_errors.rb +42 -0
  31. data/lib/shopify-client/struct.rb +10 -0
  32. data/lib/shopify-client/throttling/redis_strategy.rb +62 -0
  33. data/lib/shopify-client/throttling/strategy.rb +50 -0
  34. data/lib/shopify-client/throttling/thread_local_strategy.rb +29 -0
  35. data/lib/shopify-client/verify_request.rb +51 -0
  36. data/lib/shopify-client/verify_webhook.rb +24 -0
  37. data/lib/shopify-client/version.rb +5 -0
  38. data/lib/shopify-client/webhook.rb +32 -0
  39. data/lib/shopify-client/webhook_list.rb +50 -0
  40. 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