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.
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