shopify_api 13.4.0 → 14.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/ISSUE_TEMPLATE/BUG_REPORT.md +23 -17
- data/.github/workflows/build.yml +0 -1
- data/CHANGELOG.md +11 -0
- data/CODE_OF_CONDUCT.md +46 -0
- data/Gemfile.lock +40 -45
- data/dev.yml +3 -2
- data/docs/usage/rest.md +63 -10
- data/docs/usage/webhooks.md +34 -9
- data/lib/shopify_api/auth/oauth/access_token_response.rb +37 -0
- data/lib/shopify_api/auth/oauth.rb +2 -33
- data/lib/shopify_api/auth/session.rb +26 -0
- data/lib/shopify_api/auth/token_exchange.rb +80 -0
- data/lib/shopify_api/logger.rb +2 -2
- data/lib/shopify_api/rest/base.rb +80 -22
- data/lib/shopify_api/utils/attributes_comparator.rb +85 -0
- data/lib/shopify_api/utils/hmac_validator.rb +1 -1
- data/lib/shopify_api/version.rb +1 -1
- data/lib/shopify_api/webhooks/handler.rb +24 -1
- data/lib/shopify_api/webhooks/registration.rb +2 -2
- data/lib/shopify_api/webhooks/registry.rb +13 -2
- data/lib/shopify_api/webhooks/request.rb +10 -0
- data/lib/shopify_api.rb +1 -0
- data/shopify_api.gemspec +1 -1
- metadata +8 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0eceb8b083a02fd062ebc545e6d4a60b1b4c11fec77bead21bef052f1c184b0a
|
4
|
+
data.tar.gz: 6d47eb134e26c9536db9ee6225abf3edf00a07c15060aa6cae6d490b49347151
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 38fb4a844e00437ff2d2e93cc6c931c13ace7511aec1dcbf49585a6ea22a0f4db59cc472d46b257424645b20de2740600d8e429ed21435cf64448df6aea5f3e4
|
7
|
+
data.tar.gz: d3d4102189174b84791a7f0065bf633b548b4034fcff31305b7d350de3a3d07fdc7b6fe1a21dd8e7286583656d1bec7517ed4662aa429c17d00fcfdc9176eba2
|
@@ -6,35 +6,41 @@ labels: "Type: Bug 🐛"
|
|
6
6
|
|
7
7
|
# Issue summary
|
8
8
|
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
9
|
+
Before opening this issue, I have:
|
10
|
+
|
11
|
+
- [ ] Upgraded to the latest version of the package
|
12
|
+
- `shopify_api` version:
|
13
|
+
- Ruby version:
|
14
|
+
- Operating system:
|
15
|
+
- [ ] Set `log_level: :debug` [in my configuration](https://github.com/Shopify/shopify-api-ruby#setup-shopify-context), if applicable
|
16
|
+
- [ ] Found a reliable way to reproduce the problem that indicates it's a problem with the package
|
17
|
+
- [ ] Looked for similar issues in this repository
|
18
|
+
- [ ] Checked that this isn't an issue with a Shopify API
|
19
|
+
- If it is, please create a post in the [Shopify community forums](https://community.shopify.com/c/partners-and-developers/ct-p/appdev) or report it to [Shopify Partner Support](https://help.shopify.com/en/support/partners/org-select)
|
13
20
|
|
14
|
-
|
15
|
-
|
16
|
-
Learn more: https://github.com/Shopify/shopify-api-ruby#setup-shopify-context
|
21
|
+
<!--
|
22
|
+
Write a short description of the issue here.
|
17
23
|
|
24
|
+
We can only fix issues for which there is a clear reproduction scenario.
|
25
|
+
The more context you can provide, the easier it becomes for us to investigate and fix the issue.
|
18
26
|
-->
|
19
27
|
|
20
|
-
- `shopify_api` version:
|
21
|
-
- Ruby version:
|
22
|
-
- Operating system:
|
23
|
-
|
24
|
-
```
|
25
|
-
// Paste any relevant logs here
|
26
|
-
```
|
27
|
-
|
28
28
|
## Expected behavior
|
29
29
|
|
30
|
-
|
30
|
+
What do you think should happen?
|
31
31
|
|
32
32
|
## Actual behavior
|
33
33
|
|
34
|
-
|
34
|
+
What actually happens?
|
35
35
|
|
36
36
|
## Steps to reproduce the problem
|
37
37
|
|
38
38
|
1.
|
39
39
|
1.
|
40
40
|
1.
|
41
|
+
|
42
|
+
## Debug logs
|
43
|
+
|
44
|
+
```
|
45
|
+
// Paste any relevant logs here
|
46
|
+
```
|
data/.github/workflows/build.yml
CHANGED
data/CHANGELOG.md
CHANGED
@@ -4,6 +4,17 @@ Note: For changes to the API, see https://shopify.dev/changelog?filter=api
|
|
4
4
|
|
5
5
|
## Unreleased
|
6
6
|
|
7
|
+
## 14.0.1
|
8
|
+
- [#1288](https://github.com/Shopify/shopify-api-ruby/pull/1288) Fix FeatureDeprecatedError being raised without a message.
|
9
|
+
- [1290](https://github.com/Shopify/shopify-api-ruby/pull/1290) Move deprecation of `ShopifyAPI::Webhooks::Handler#handle` to version 15.0.0
|
10
|
+
|
11
|
+
## 14.0.0
|
12
|
+
- [#1274](https://github.com/Shopify/shopify-api-ruby/pull/1274) ⚠️ [Breaking] Update sorbet and rbi dependencies. Remove support for ruby 2.7. Minimum required Ruby version is 3.0
|
13
|
+
- [#1282](https://github.com/Shopify/shopify-api-ruby/pull/1282) Fixes a bug where diffing attributes to update not take into account of Array changes and required ids.
|
14
|
+
- [#1254](https://github.com/Shopify/shopify-api-ruby/pull/1254) Introduce token exchange API for fetching access tokens. This feature is currently unstable and cannot be used yet.
|
15
|
+
- [#1268](https://github.com/Shopify/shopify-api-ruby/pull/1268) Add [new webhook handler interface](https://github.com/Shopify/shopify-api-ruby/blob/main/docs/usage/webhooks.md#create-a-webhook-handler) to provide `webhook_id ` and `api_version` information to webhook handlers.
|
16
|
+
- [#1275](https://github.com/Shopify/shopify-api-ruby/pull/1275) Allow adding custom headers in REST Resource HTTP calls.
|
17
|
+
|
7
18
|
## 13.4.0
|
8
19
|
- [#1210](https://github.com/Shopify/shopify-api-ruby/pull/1246) Add context option `response_as_struct` to allow GraphQL API responses to be accessed via dot notation.
|
9
20
|
- [#1257](https://github.com/Shopify/shopify-api-ruby/pull/1257) Add `api_call_limit` and `retry_request_after` to REST resources to expose rate limit information.
|
data/CODE_OF_CONDUCT.md
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
# Contributor Code of Conduct
|
2
|
+
|
3
|
+
As contributors and maintainers of this project, and in the interest of
|
4
|
+
fostering an open and welcoming community, we pledge to respect all
|
5
|
+
people who contribute through reporting issues, posting feature
|
6
|
+
requests, updating documentation, submitting pull requests or patches,
|
7
|
+
and other activities.
|
8
|
+
|
9
|
+
We are committed to making participation in this project a
|
10
|
+
harassment-free experience for everyone, regardless of level of
|
11
|
+
experience, gender, gender identity and expression, sexual orientation,
|
12
|
+
disability, personal appearance, body size, race, ethnicity, age,
|
13
|
+
religion, or nationality.
|
14
|
+
|
15
|
+
Examples of unacceptable behavior by participants include:
|
16
|
+
|
17
|
+
- The use of sexualized language or imagery
|
18
|
+
- Personal attacks
|
19
|
+
- Trolling or insulting/derogatory comments
|
20
|
+
- Public or private harassment
|
21
|
+
- Publishing other's private information, such as physical or electronic
|
22
|
+
addresses, without explicit permission
|
23
|
+
- Other unethical or unprofessional conduct
|
24
|
+
|
25
|
+
Project maintainers have the right and responsibility to remove, edit,
|
26
|
+
or reject comments, commits, code, wiki edits, issues, and other
|
27
|
+
contributions that are not aligned to this Code of Conduct, or to ban
|
28
|
+
temporarily or permanently any contributor for other behaviors that they
|
29
|
+
deem inappropriate, threatening, offensive, or harmful.
|
30
|
+
|
31
|
+
By adopting this Code of Conduct, project maintainers commit themselves
|
32
|
+
to fairly and consistently applying these principles to every aspect of
|
33
|
+
managing this project. Project maintainers who do not follow or enforce
|
34
|
+
the Code of Conduct may be permanently removed from the project team.
|
35
|
+
|
36
|
+
This Code of Conduct applies both within project spaces and in public
|
37
|
+
spaces when an individual is representing the project or its community.
|
38
|
+
|
39
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may
|
40
|
+
be reported by contacting a project maintainer at <opensource@shopify.com>.
|
41
|
+
All complaints will be reviewed and investigated and will result in a response
|
42
|
+
that is deemed necessary and appropriate to the circumstances. Maintainers are
|
43
|
+
obligated to maintain confidentiality with regard to the reporter of an incident.
|
44
|
+
|
45
|
+
This Code of Conduct is adapted from the Contributor Covenant, version
|
46
|
+
1.3.0, available from http://contributor-covenant.org/version/1/3/0/
|
data/Gemfile.lock
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
shopify_api (
|
4
|
+
shopify_api (14.0.1)
|
5
5
|
activesupport
|
6
6
|
concurrent-ruby
|
7
7
|
hash_diff
|
@@ -16,7 +16,7 @@ PATH
|
|
16
16
|
GEM
|
17
17
|
remote: https://rubygems.org/
|
18
18
|
specs:
|
19
|
-
activesupport (7.1.2)
|
19
|
+
activesupport (7.1.3.2)
|
20
20
|
base64
|
21
21
|
bigdecimal
|
22
22
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
@@ -30,26 +30,26 @@ GEM
|
|
30
30
|
public_suffix (>= 2.0.2, < 5.0)
|
31
31
|
ast (2.4.2)
|
32
32
|
base64 (0.2.0)
|
33
|
-
bigdecimal (3.1.
|
33
|
+
bigdecimal (3.1.6)
|
34
34
|
byebug (11.1.3)
|
35
35
|
coderay (1.1.3)
|
36
|
-
concurrent-ruby (1.2.
|
36
|
+
concurrent-ruby (1.2.3)
|
37
37
|
connection_pool (2.4.1)
|
38
38
|
crack (0.4.5)
|
39
39
|
rexml
|
40
|
-
|
41
|
-
|
42
|
-
ruby2_keywords
|
40
|
+
drb (2.2.1)
|
41
|
+
erubi (1.12.0)
|
43
42
|
fakefs (1.4.1)
|
44
43
|
hash_diff (1.1.1)
|
45
44
|
hashdiff (1.0.1)
|
46
45
|
httparty (0.21.0)
|
47
46
|
mini_mime (>= 1.0.0)
|
48
47
|
multi_xml (>= 0.5.2)
|
49
|
-
i18n (1.14.
|
48
|
+
i18n (1.14.4)
|
50
49
|
concurrent-ruby (~> 1.0)
|
51
50
|
json (2.6.2)
|
52
|
-
jwt (2.
|
51
|
+
jwt (2.8.1)
|
52
|
+
base64
|
53
53
|
method_source (1.0.0)
|
54
54
|
mini_mime (1.1.5)
|
55
55
|
minitest (5.15.0)
|
@@ -60,25 +60,25 @@ GEM
|
|
60
60
|
oj (3.16.3)
|
61
61
|
bigdecimal (>= 3.0)
|
62
62
|
openssl (3.2.0)
|
63
|
-
parallel (1.
|
64
|
-
parser (3.
|
63
|
+
parallel (1.24.0)
|
64
|
+
parser (3.3.0.5)
|
65
65
|
ast (~> 2.4.1)
|
66
66
|
racc
|
67
|
-
|
67
|
+
prettier_print (1.2.1)
|
68
|
+
prism (0.21.0)
|
69
|
+
pry (0.14.2)
|
68
70
|
coderay (~> 1.1)
|
69
71
|
method_source (~> 1.0)
|
70
72
|
pry-byebug (3.10.1)
|
71
73
|
byebug (~> 11.0)
|
72
74
|
pry (>= 0.13, < 0.15)
|
73
75
|
public_suffix (4.0.6)
|
74
|
-
racc (1.7.
|
76
|
+
racc (1.7.3)
|
75
77
|
rainbow (3.1.1)
|
76
78
|
rake (13.0.6)
|
77
|
-
rbi (0.
|
78
|
-
|
79
|
-
parser (>= 2.6.4.0)
|
79
|
+
rbi (0.1.8)
|
80
|
+
prism (>= 0.18.0, < 0.22)
|
80
81
|
sorbet-runtime (>= 0.5.9204)
|
81
|
-
unparser
|
82
82
|
regexp_parser (2.5.0)
|
83
83
|
rexml (3.2.5)
|
84
84
|
rubocop (1.36.0)
|
@@ -98,49 +98,44 @@ GEM
|
|
98
98
|
rubocop-sorbet (0.6.11)
|
99
99
|
rubocop (>= 0.90.0)
|
100
100
|
ruby-progressbar (1.11.0)
|
101
|
-
ruby2_keywords (0.0.5)
|
102
101
|
securerandom (0.3.1)
|
103
|
-
sorbet (0.5.
|
104
|
-
sorbet-static (= 0.5.
|
105
|
-
sorbet-runtime (0.5.
|
106
|
-
sorbet-static (0.5.
|
107
|
-
sorbet-static (0.5.
|
108
|
-
sorbet-static (0.5.
|
109
|
-
|
110
|
-
sorbet (= 0.5.
|
111
|
-
|
112
|
-
|
113
|
-
sorbet (>= 0.5.
|
114
|
-
|
102
|
+
sorbet (0.5.11230)
|
103
|
+
sorbet-static (= 0.5.11230)
|
104
|
+
sorbet-runtime (0.5.11230)
|
105
|
+
sorbet-static (0.5.11230-universal-darwin)
|
106
|
+
sorbet-static (0.5.11230-x86_64-linux)
|
107
|
+
sorbet-static-and-runtime (0.5.11230)
|
108
|
+
sorbet (= 0.5.11230)
|
109
|
+
sorbet-runtime (= 0.5.11230)
|
110
|
+
spoom (1.2.4)
|
111
|
+
erubi (>= 1.10.0)
|
112
|
+
sorbet-static-and-runtime (>= 0.5.10187)
|
113
|
+
syntax_tree (>= 6.1.1)
|
115
114
|
thor (>= 0.19.2)
|
116
|
-
|
117
|
-
|
115
|
+
syntax_tree (6.2.0)
|
116
|
+
prettier_print (>= 1.2.0)
|
117
|
+
tapioca (0.12.0)
|
118
|
+
bundler (>= 2.2.25)
|
118
119
|
netrc (>= 0.11.0)
|
119
120
|
parallel (>= 1.21.0)
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
spoom (~> 1.1.0, >= 1.1.11)
|
121
|
+
rbi (>= 0.1.4, < 0.2)
|
122
|
+
sorbet-static-and-runtime (>= 0.5.10820)
|
123
|
+
spoom (~> 1.2.0, >= 1.2.0)
|
124
124
|
thor (>= 1.2.0)
|
125
125
|
yard-sorbet
|
126
|
-
thor (1.
|
126
|
+
thor (1.3.0)
|
127
127
|
tzinfo (2.0.6)
|
128
128
|
concurrent-ruby (~> 1.0)
|
129
129
|
unicode-display_width (2.3.0)
|
130
|
-
unparser (0.6.5)
|
131
|
-
diff-lcs (~> 1.3)
|
132
|
-
parser (>= 3.1.0)
|
133
130
|
webmock (3.14.0)
|
134
131
|
addressable (>= 2.8.0)
|
135
132
|
crack (>= 0.3.2)
|
136
133
|
hashdiff (>= 0.4.0, < 2.0.0)
|
137
|
-
|
138
|
-
yard (0.
|
139
|
-
webrick (~> 1.7.0)
|
140
|
-
yard-sorbet (0.7.0)
|
134
|
+
yard (0.9.34)
|
135
|
+
yard-sorbet (0.8.1)
|
141
136
|
sorbet-runtime (>= 0.5)
|
142
137
|
yard (>= 0.9)
|
143
|
-
zeitwerk (2.6.
|
138
|
+
zeitwerk (2.6.13)
|
144
139
|
|
145
140
|
PLATFORMS
|
146
141
|
arm64-darwin-21
|
data/dev.yml
CHANGED
data/docs/usage/rest.md
CHANGED
@@ -69,18 +69,18 @@ Typical methods provided for each resources are:
|
|
69
69
|
Full list of methods can be found on each of the resource class.
|
70
70
|
- Path:
|
71
71
|
- https://github.com/Shopify/shopify-api-ruby/blob/main/lib/shopify_api/rest/resources/#{version}/#{resource}.rb
|
72
|
-
- Example for `Order` resource on `
|
73
|
-
- https://github.com/Shopify/shopify-api-ruby/blob/main/lib/shopify_api/rest/resources/
|
72
|
+
- Example for `Order` resource on `2024-01` version:
|
73
|
+
- https://github.com/Shopify/shopify-api-ruby/blob/main/lib/shopify_api/rest/resources/2024_01/order.rb
|
74
74
|
|
75
|
-
###
|
76
|
-
⚠️ Reference documentation on [shopify.dev](https://shopify.dev/docs/api/admin-rest) contains more examples on how to use each REST Resources.
|
75
|
+
### The `save` method
|
77
76
|
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
customer.email = "steve-lastnameson@example.com"
|
82
|
-
customer.save!
|
77
|
+
The `save` or `save!` method on a resource allows you to `create` or `update` that resource.
|
78
|
+
|
79
|
+
#### Create a new resource
|
83
80
|
|
81
|
+
To create a new resource using the `save` or `save!` method, you can initialize the resource with a hash of values or simply assigning them manually. For example:
|
82
|
+
|
83
|
+
```Ruby
|
84
84
|
# Create a new product from hash
|
85
85
|
product_properties = {
|
86
86
|
title: "My awesome product"
|
@@ -88,10 +88,63 @@ product_properties = {
|
|
88
88
|
product = ShopifyAPI::Product.new(from_hash: product_properties)
|
89
89
|
product.save!
|
90
90
|
|
91
|
-
# Create a product manually
|
91
|
+
# Create a new product manually
|
92
92
|
product = ShopifyAPI::Product.new
|
93
93
|
product.title = "Another one"
|
94
94
|
product.save!
|
95
|
+
```
|
96
|
+
|
97
|
+
#### Update an existing resource
|
98
|
+
|
99
|
+
To update an existing resource using the `save` or `save!` method, you'll need to fetch the resource from Shopify first. Then, you can manually assign new values to the resource before calling `save` or `save!`. For example:
|
100
|
+
|
101
|
+
```Ruby
|
102
|
+
# Update a product's title
|
103
|
+
product = ShopifyAPI::Product.find(id: product_id)
|
104
|
+
product.title = "My new title"
|
105
|
+
product.save!
|
106
|
+
|
107
|
+
# Remove a line item from a draft order
|
108
|
+
draft_order = ShopifyAPI::DraftOrder.find(id: draft_order_id)
|
109
|
+
|
110
|
+
new_line_items = draft_order.line_items.reject { |line_item| line_item["id"] == 12345 }
|
111
|
+
draft_order.line_items = new_line_items
|
112
|
+
|
113
|
+
draft_order.save!
|
114
|
+
```
|
115
|
+
|
116
|
+
> [!IMPORTANT]
|
117
|
+
> If you need to unset an existing value,
|
118
|
+
> please explicitly set that attribute to `nil` or empty values such as `[]` or `{}`. For example:
|
119
|
+
>
|
120
|
+
> ```Ruby
|
121
|
+
> # Removes shipping address from draft_order
|
122
|
+
> draft_order.shipping_address = {}
|
123
|
+
> draft_order.save!
|
124
|
+
> ```
|
125
|
+
>
|
126
|
+
> This is because only modified values are sent to the API, so if `shipping_address` is not "modified" to `{}`. It won't be part of the PUT request payload
|
127
|
+
|
128
|
+
When updating a resource, only the modified attributes, the resource's primary key, and required parameters are sent to the API. The primary key is usually the `id` attribute of the resource, but it can vary if the `primary_key` method is overwritten in the resource's class. The required parameters are identified using the path parameters of the `PUT` endpoint of the resource.
|
129
|
+
|
130
|
+
### Headers
|
131
|
+
You can add custom headers to the HTTP calls made by methods like `find`, `delete`, `all`, `count`
|
132
|
+
by setting the `headers` attribute on the `ShopifyAPI::Rest::Base` class in an initializer, like so:
|
133
|
+
|
134
|
+
```ruby
|
135
|
+
ShopifyAPI::Rest::Base.headers = { "X-Custom-Header" => "Custom Value" }
|
136
|
+
# `find` will call the API endpoint with the custom header
|
137
|
+
ShopifyAPI::Customer.find(id: customer_id)
|
138
|
+
```
|
139
|
+
|
140
|
+
### Usage Examples
|
141
|
+
⚠️ The [API reference documentation](https://shopify.dev/docs/api/admin-rest) contains more examples on how to use each REST Resources.
|
142
|
+
|
143
|
+
```Ruby
|
144
|
+
# Find and update a customer email
|
145
|
+
customer = ShopifyAPI::Customer.find(id: customer_id)
|
146
|
+
customer.email = "steve-lastnameson@example.com"
|
147
|
+
customer.save!
|
95
148
|
|
96
149
|
# Get all orders
|
97
150
|
orders = ShopifyAPI::Orders.all
|
data/docs/usage/webhooks.md
CHANGED
@@ -7,10 +7,37 @@ If using in the Rails framework, we highly recommend you use the [shopify_app](h
|
|
7
7
|
|
8
8
|
## Create a Webhook Handler
|
9
9
|
|
10
|
-
If you want to register for an http webhook you need to implement a webhook handler which the `shopify_api` gem can use to determine how to process your webhook. You can make multiple implementations (one per topic) or you can make one implementation capable of handling all the topics you want to subscribe to. To do this simply make a module or class that includes or extends `ShopifyAPI::Webhooks::WebhookHandler` and implement the handle method which accepts the following named parameters:
|
10
|
+
If you want to register for an http webhook you need to implement a webhook handler which the `shopify_api` gem can use to determine how to process your webhook. You can make multiple implementations (one per topic) or you can make one implementation capable of handling all the topics you want to subscribe to. To do this simply make a module or class that includes or extends `ShopifyAPI::Webhooks::WebhookHandler` and implement the `handle` method which accepts the following named parameters: data: `WebhookMetadata`. An example implementation is shown below:
|
11
|
+
|
12
|
+
`data` will have the following keys
|
13
|
+
- `topic`, `String` - The topic of the webhook
|
14
|
+
- `shop`, `String` - The shop domain of the webhook
|
15
|
+
- `body`, `T::Hash[String, T.untyped]`- The body of the webhook
|
16
|
+
- `webhook_id`, `String` - The id of the webhook event to [avoid duplicates](https://shopify.dev/docs/apps/webhooks/best-practices#ignore-duplicates)
|
17
|
+
- `api_version`, `String` - The api version of the webhook
|
18
|
+
|
19
|
+
```ruby
|
20
|
+
module WebhookHandler
|
21
|
+
extend ShopifyAPI::Webhooks::WebhookHandler
|
22
|
+
|
23
|
+
class << self
|
24
|
+
def handle_webhook(data:)
|
25
|
+
puts "Received webhook! topic: #{data.topic} shop: #{data.shop} body: #{data.body} webhook_id: #{data.webhook_id} api_version: #{data.api_version"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
```
|
30
|
+
|
31
|
+
**Note:** As of version 13.5.0 the `ShopifyAPI::Webhooks::Handler` class is still available to be used but will be removed in a future version of the gem.
|
32
|
+
|
33
|
+
### Best Practices
|
34
|
+
It is recommended that in order to respond quickly to the Shopify webhook request that the handler not do any heavy logic or network calls, rather it should simply enqueue the work in some job queue in order to be executed later.
|
35
|
+
|
36
|
+
### Webhook Handler for versions 13.4.0 and prior
|
37
|
+
If you want to register for an http webhook you need to implement a webhook handler which the `shopify_api` gem can use to determine how to process your webhook. You can make multiple implementations (one per topic) or you can make one implementation capable of handling all the topics you want to subscribe to. To do this simply make a module or class that includes or extends `ShopifyAPI::Webhooks::Handler` and implement the handle method which accepts the following named parameters: topic: `String`, shop: `String`, and body: `Hash[String, untyped]`. An example implementation is shown below:
|
11
38
|
|
12
39
|
```ruby
|
13
|
-
module WebhookHandler
|
40
|
+
module WebhookHandler
|
14
41
|
extend ShopifyAPI::Webhooks::Handler
|
15
42
|
|
16
43
|
class << self
|
@@ -21,25 +48,23 @@ module WebhookHandler
|
|
21
48
|
end
|
22
49
|
```
|
23
50
|
|
24
|
-
**Note:** It is recommended that in order to respond quickly to the Shopify webhook request that the handler not do any heavy logic or network calls, rather it should simply enqueue the work in some job queue in order to be executed later.
|
25
|
-
|
26
51
|
## Add to Webhook Registry
|
27
52
|
|
28
53
|
The next step is to add all the webhooks you would like to subscribe to for any shop to the webhook registry. To do this you can call `ShopifyAPI::Webhooks::Registry.add_registration` for each webhook you would like to handle. `add_registration` accepts a topic string, a delivery_method symbol (currently supporting `:http`, `:event_bridge`, and `:pub_sub`), a webhook path (the relative path for an http webhook) and a handler. This only needs to be done once when the app is started and we recommend doing this at the same time that you setup `ShopifyAPI::Context`. An example is shown below to register an http webhook:
|
29
54
|
|
30
55
|
```ruby
|
31
|
-
registration = ShopifyAPI::Webhooks::Registry.add_registration(topic: "orders/create",
|
56
|
+
registration = ShopifyAPI::Webhooks::Registry.add_registration(topic: "orders/create",
|
32
57
|
delivery_method: :http,
|
33
58
|
handler: WebhookHandler,
|
34
|
-
path: 'callback/orders/create')
|
59
|
+
path: 'callback/orders/create')
|
35
60
|
```
|
36
61
|
If you are only interested in particular fields, you can optionally filter the data sent by Shopify by specifying the `fields` parameter. Note that you will still receive a webhook request from Shopify every time the resource is updated, but only the specified fields will be sent:
|
37
62
|
|
38
63
|
```ruby
|
39
64
|
registration = ShopifyAPI::Webhooks::Registry.add_registration(
|
40
|
-
topic: "orders/create",
|
41
|
-
delivery_method: :http,
|
42
|
-
handler: WebhookHandler,
|
65
|
+
topic: "orders/create",
|
66
|
+
delivery_method: :http,
|
67
|
+
handler: WebhookHandler,
|
43
68
|
path: 'callback/orders/create',
|
44
69
|
fields: ["number","note"] # this can also be a single comma separated string
|
45
70
|
)
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module ShopifyAPI
|
5
|
+
module Auth
|
6
|
+
module Oauth
|
7
|
+
class AccessTokenResponse < T::Struct
|
8
|
+
extend T::Sig
|
9
|
+
|
10
|
+
const :access_token, String
|
11
|
+
const :scope, String
|
12
|
+
const :session, T.nilable(String)
|
13
|
+
const :expires_in, T.nilable(Integer)
|
14
|
+
const :associated_user, T.nilable(AssociatedUser)
|
15
|
+
const :associated_user_scope, T.nilable(String)
|
16
|
+
|
17
|
+
sig { returns(T::Boolean) }
|
18
|
+
def online_token?
|
19
|
+
!associated_user.nil?
|
20
|
+
end
|
21
|
+
|
22
|
+
alias_method :eql?, :==
|
23
|
+
sig { params(other: T.nilable(AccessTokenResponse)).returns(T::Boolean) }
|
24
|
+
def ==(other)
|
25
|
+
return false unless other
|
26
|
+
|
27
|
+
access_token == other.access_token &&
|
28
|
+
scope == other.scope &&
|
29
|
+
session == other.session &&
|
30
|
+
expires_in == other.expires_in &&
|
31
|
+
associated_user == other.associated_user &&
|
32
|
+
associated_user_scope == other.associated_user_scope
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -89,7 +89,8 @@ module ShopifyAPI
|
|
89
89
|
end
|
90
90
|
|
91
91
|
session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h
|
92
|
-
session =
|
92
|
+
session = Session.from(shop: auth_query.shop,
|
93
|
+
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params))
|
93
94
|
|
94
95
|
cookie = if Context.embedded?
|
95
96
|
SessionCookie.new(
|
@@ -105,38 +106,6 @@ module ShopifyAPI
|
|
105
106
|
|
106
107
|
{ session: session, cookie: cookie }
|
107
108
|
end
|
108
|
-
|
109
|
-
private
|
110
|
-
|
111
|
-
sig { params(session_params: T::Hash[String, T.untyped], shop: String).returns(Session) }
|
112
|
-
def create_new_session(session_params, shop)
|
113
|
-
session_params = session_params.to_h { |k, v| [k.to_sym, v] }
|
114
|
-
|
115
|
-
scope = session_params[:scope]
|
116
|
-
|
117
|
-
is_online = !session_params[:associated_user].nil?
|
118
|
-
|
119
|
-
if is_online
|
120
|
-
associated_user = AssociatedUser.new(session_params[:associated_user].to_h { |k, v| [k.to_sym, v] })
|
121
|
-
expires = Time.now + session_params[:expires_in].to_i
|
122
|
-
associated_user_scope = session_params[:associated_user_scope]
|
123
|
-
id = "#{shop}_#{associated_user.id}"
|
124
|
-
else
|
125
|
-
id = "offline_#{shop}"
|
126
|
-
end
|
127
|
-
|
128
|
-
Session.new(
|
129
|
-
id: id,
|
130
|
-
shop: shop,
|
131
|
-
access_token: session_params[:access_token],
|
132
|
-
scope: scope,
|
133
|
-
is_online: is_online,
|
134
|
-
associated_user_scope: associated_user_scope,
|
135
|
-
associated_user: associated_user,
|
136
|
-
expires: expires,
|
137
|
-
shopify_session_id: session_params[:session],
|
138
|
-
)
|
139
|
-
end
|
140
109
|
end
|
141
110
|
end
|
142
111
|
end
|
@@ -89,6 +89,32 @@ module ShopifyAPI
|
|
89
89
|
end
|
90
90
|
end
|
91
91
|
|
92
|
+
sig { params(shop: String, access_token_response: Oauth::AccessTokenResponse).returns(Session) }
|
93
|
+
def from(shop:, access_token_response:)
|
94
|
+
is_online = access_token_response.online_token?
|
95
|
+
|
96
|
+
if is_online
|
97
|
+
associated_user = T.must(access_token_response.associated_user)
|
98
|
+
expires = Time.now + access_token_response.expires_in.to_i
|
99
|
+
associated_user_scope = access_token_response.associated_user_scope
|
100
|
+
id = "#{shop}_#{associated_user.id}"
|
101
|
+
else
|
102
|
+
id = "offline_#{shop}"
|
103
|
+
end
|
104
|
+
|
105
|
+
new(
|
106
|
+
id: id,
|
107
|
+
shop: shop,
|
108
|
+
access_token: access_token_response.access_token,
|
109
|
+
scope: access_token_response.scope,
|
110
|
+
is_online: is_online,
|
111
|
+
associated_user_scope: associated_user_scope,
|
112
|
+
associated_user: associated_user,
|
113
|
+
expires: expires,
|
114
|
+
shopify_session_id: access_token_response.session,
|
115
|
+
)
|
116
|
+
end
|
117
|
+
|
92
118
|
sig { params(str: String).returns(Session) }
|
93
119
|
def deserialize(str)
|
94
120
|
Oj.load(str)
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module ShopifyAPI
|
5
|
+
module Auth
|
6
|
+
module TokenExchange
|
7
|
+
extend T::Sig
|
8
|
+
|
9
|
+
TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
|
10
|
+
ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token"
|
11
|
+
|
12
|
+
class RequestedTokenType < T::Enum
|
13
|
+
enums do
|
14
|
+
ONLINE_ACCESS_TOKEN = new("urn:shopify:params:oauth:token-type:online-access-token")
|
15
|
+
OFFLINE_ACCESS_TOKEN = new("urn:shopify:params:oauth:token-type:offline-access-token")
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
class << self
|
20
|
+
extend T::Sig
|
21
|
+
|
22
|
+
sig do
|
23
|
+
params(
|
24
|
+
shop: String,
|
25
|
+
session_token: String,
|
26
|
+
requested_token_type: RequestedTokenType,
|
27
|
+
).returns(ShopifyAPI::Auth::Session)
|
28
|
+
end
|
29
|
+
def exchange_token(shop:, session_token:, requested_token_type:)
|
30
|
+
unless ShopifyAPI::Context.setup?
|
31
|
+
raise ShopifyAPI::Errors::ContextNotSetupError,
|
32
|
+
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
|
33
|
+
end
|
34
|
+
raise ShopifyAPI::Errors::UnsupportedOauthError,
|
35
|
+
"Cannot perform OAuth Token Exchange for private apps." if ShopifyAPI::Context.private?
|
36
|
+
raise ShopifyAPI::Errors::UnsupportedOauthError,
|
37
|
+
"Cannot perform OAuth Token Exchange for non embedded apps." unless ShopifyAPI::Context.embedded?
|
38
|
+
|
39
|
+
# Validate the session token content
|
40
|
+
ShopifyAPI::Auth::JwtPayload.new(session_token)
|
41
|
+
|
42
|
+
shop_session = ShopifyAPI::Auth::Session.new(shop: shop)
|
43
|
+
body = {
|
44
|
+
client_id: ShopifyAPI::Context.api_key,
|
45
|
+
client_secret: ShopifyAPI::Context.api_secret_key,
|
46
|
+
grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
|
47
|
+
subject_token: session_token,
|
48
|
+
subject_token_type: ID_TOKEN_TYPE,
|
49
|
+
requested_token_type: requested_token_type.serialize,
|
50
|
+
}
|
51
|
+
|
52
|
+
client = Clients::HttpClient.new(session: shop_session, base_path: "/admin/oauth")
|
53
|
+
response = begin
|
54
|
+
client.request(
|
55
|
+
Clients::HttpRequest.new(
|
56
|
+
http_method: :post,
|
57
|
+
path: "access_token",
|
58
|
+
body: body,
|
59
|
+
body_type: "application/json",
|
60
|
+
),
|
61
|
+
)
|
62
|
+
rescue ShopifyAPI::Errors::HttpResponseError => error
|
63
|
+
if error.code == 400 && error.response.body["error"] == "invalid_subject_token"
|
64
|
+
raise ShopifyAPI::Errors::InvalidJwtTokenError, "Session token was rejected by token exchange"
|
65
|
+
end
|
66
|
+
|
67
|
+
raise error
|
68
|
+
end
|
69
|
+
|
70
|
+
session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h
|
71
|
+
|
72
|
+
Session.from(
|
73
|
+
shop: shop,
|
74
|
+
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
|
75
|
+
)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
data/lib/shopify_api/logger.rb
CHANGED
@@ -33,7 +33,7 @@ module ShopifyAPI
|
|
33
33
|
def deprecated(message, version)
|
34
34
|
return unless enabled_for_log_level?(:warn)
|
35
35
|
|
36
|
-
raise Errors::FeatureDeprecatedError unless valid_version(version)
|
36
|
+
raise Errors::FeatureDeprecatedError, message unless valid_version(version)
|
37
37
|
|
38
38
|
send_to_logger(:warn, message)
|
39
39
|
end
|
@@ -75,7 +75,7 @@ module ShopifyAPI
|
|
75
75
|
def valid_version(version)
|
76
76
|
current_version = Gem::Version.create(ShopifyAPI::VERSION)
|
77
77
|
deprecate_version = Gem::Version.create(version)
|
78
|
-
current_version < deprecate_version
|
78
|
+
T.must(current_version) < deprecate_version
|
79
79
|
end
|
80
80
|
end
|
81
81
|
end
|
@@ -2,7 +2,6 @@
|
|
2
2
|
# frozen_string_literal: true
|
3
3
|
|
4
4
|
require "active_support/inflector"
|
5
|
-
require "hash_diff"
|
6
5
|
|
7
6
|
module ShopifyAPI
|
8
7
|
module Rest
|
@@ -11,8 +10,9 @@ module ShopifyAPI
|
|
11
10
|
extend T::Helpers
|
12
11
|
abstract!
|
13
12
|
|
14
|
-
@
|
15
|
-
@
|
13
|
+
@headers = T.let(nil, T.nilable(T::Hash[T.any(Symbol, String), String]))
|
14
|
+
@has_one = T.let({}, T::Hash[Symbol, T::Class[T.anything]])
|
15
|
+
@has_many = T.let({}, T::Hash[Symbol, T::Class[T.anything]])
|
16
16
|
@paths = T.let([], T::Array[T::Hash[Symbol, T.any(T::Array[Symbol], String, Symbol)]])
|
17
17
|
@custom_prefix = T.let(nil, T.nilable(String))
|
18
18
|
@read_only_attributes = T.let([], T.nilable(T::Array[Symbol]))
|
@@ -55,12 +55,24 @@ module ShopifyAPI
|
|
55
55
|
sig { returns(T.nilable(String)) }
|
56
56
|
attr_reader :custom_prefix
|
57
57
|
|
58
|
-
sig { returns(T::Hash[Symbol, Class]) }
|
58
|
+
sig { returns(T::Hash[Symbol, T::Class[T.anything]]) }
|
59
59
|
attr_reader :has_many
|
60
60
|
|
61
|
-
sig { returns(T::Hash[Symbol, Class]) }
|
61
|
+
sig { returns(T::Hash[Symbol, T::Class[T.anything]]) }
|
62
62
|
attr_reader :has_one
|
63
63
|
|
64
|
+
sig { returns(T.nilable(T::Hash[T.any(Symbol, String), String])) }
|
65
|
+
attr_accessor :headers
|
66
|
+
|
67
|
+
sig { params(subclass: T::Class[T.anything]).returns(T.untyped) }
|
68
|
+
def inherited(subclass)
|
69
|
+
super
|
70
|
+
|
71
|
+
subclass.define_singleton_method(:headers) do
|
72
|
+
ShopifyAPI::Rest::Base.headers
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
64
76
|
sig do
|
65
77
|
params(
|
66
78
|
session: T.nilable(Auth::Session),
|
@@ -74,7 +86,7 @@ module ShopifyAPI
|
|
74
86
|
client = ShopifyAPI::Clients::Rest::Admin.new(session: session)
|
75
87
|
|
76
88
|
path = T.must(get_path(http_method: :get, operation: :get, ids: ids))
|
77
|
-
response = client.get(path: path, query: params.compact)
|
89
|
+
response = client.get(path: path, query: params.compact, headers: headers)
|
78
90
|
|
79
91
|
instance_variable_get(:"@prev_page_info").value = response.prev_page_info
|
80
92
|
instance_variable_get(:"@next_page_info").value = response.next_page_info
|
@@ -187,6 +199,21 @@ module ShopifyAPI
|
|
187
199
|
custom_prefix ? "#{T.must(custom_prefix).sub(%r{\A/}, "")}/#{match}" : match
|
188
200
|
end
|
189
201
|
|
202
|
+
sig do
|
203
|
+
params(
|
204
|
+
http_method: Symbol,
|
205
|
+
operation: Symbol,
|
206
|
+
).returns(T.nilable(T::Array[Symbol]))
|
207
|
+
end
|
208
|
+
def get_path_ids(http_method:, operation:)
|
209
|
+
found_path = @paths.find do |path|
|
210
|
+
http_method == path[:http_method] && operation == path[:operation]
|
211
|
+
end
|
212
|
+
return nil if found_path.nil?
|
213
|
+
|
214
|
+
T.cast(found_path[:ids], T::Array[Symbol])
|
215
|
+
end
|
216
|
+
|
190
217
|
sig do
|
191
218
|
params(
|
192
219
|
http_method: Symbol,
|
@@ -205,13 +232,13 @@ module ShopifyAPI
|
|
205
232
|
|
206
233
|
case http_method
|
207
234
|
when :get
|
208
|
-
client.get(path: T.must(path), query: params.compact)
|
235
|
+
client.get(path: T.must(path), query: params.compact, headers: headers)
|
209
236
|
when :post
|
210
|
-
client.post(path: T.must(path), query: params.compact, body: body || {})
|
237
|
+
client.post(path: T.must(path), query: params.compact, body: body || {}, headers: headers)
|
211
238
|
when :put
|
212
|
-
client.put(path: T.must(path), query: params.compact, body: body || {})
|
239
|
+
client.put(path: T.must(path), query: params.compact, body: body || {}, headers: headers)
|
213
240
|
when :delete
|
214
|
-
client.delete(path: T.must(path), query: params.compact)
|
241
|
+
client.delete(path: T.must(path), query: params.compact, headers: headers)
|
215
242
|
else
|
216
243
|
raise Errors::InvalidHttpRequestError, "Invalid HTTP method: #{http_method}"
|
217
244
|
end
|
@@ -341,6 +368,7 @@ module ShopifyAPI
|
|
341
368
|
@client.delete(
|
342
369
|
path: T.must(self.class.get_path(http_method: :delete, operation: :delete, entity: self)),
|
343
370
|
query: params.compact,
|
371
|
+
headers: self.class.headers,
|
344
372
|
)
|
345
373
|
rescue ShopifyAPI::Errors::HttpResponseError => e
|
346
374
|
@errors.errors << e
|
@@ -355,10 +383,16 @@ module ShopifyAPI
|
|
355
383
|
sig { params(update_object: T::Boolean).void }
|
356
384
|
def save(update_object: false)
|
357
385
|
method = deduce_write_verb
|
386
|
+
|
387
|
+
body = {
|
388
|
+
self.class.json_body_name => attributes_to_update.merge(build_required_attributes(http_method: method)),
|
389
|
+
}
|
390
|
+
|
358
391
|
response = @client.public_send(
|
359
392
|
method,
|
360
|
-
body:
|
393
|
+
body: body,
|
361
394
|
path: deduce_write_path(method),
|
395
|
+
headers: self.class.headers,
|
362
396
|
)
|
363
397
|
|
364
398
|
if update_object
|
@@ -374,22 +408,34 @@ module ShopifyAPI
|
|
374
408
|
|
375
409
|
sig { returns(T::Hash[String, String]) }
|
376
410
|
def attributes_to_update
|
377
|
-
|
411
|
+
updatable_attributes = original_state.reject do |attribute, _|
|
378
412
|
self.class.read_only_attributes&.include?("@#{attribute}".to_sym)
|
379
413
|
end
|
380
414
|
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
415
|
+
stringified_updatable_attributes = deep_stringify_keys(updatable_attributes)
|
416
|
+
stringified_new_attributes = deep_stringify_keys(to_hash(true))
|
417
|
+
ShopifyAPI::Utils::AttributesComparator.compare(
|
418
|
+
stringified_updatable_attributes,
|
419
|
+
stringified_new_attributes,
|
420
|
+
)
|
421
|
+
end
|
385
422
|
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
423
|
+
sig { params(http_method: Symbol).returns(T::Hash[String, T.untyped]) }
|
424
|
+
def build_required_attributes(http_method:)
|
425
|
+
required_attributes = {}
|
426
|
+
|
427
|
+
primary_key_value = send(self.class.primary_key)
|
428
|
+
unless primary_key_value.nil?
|
429
|
+
required_attributes[self.class.primary_key] = primary_key_value
|
430
|
+
end
|
431
|
+
|
432
|
+
path_ids = deduce_path_ids(http_method)
|
433
|
+
path_ids&.each do |path_id|
|
434
|
+
path_id_value = send(path_id)
|
435
|
+
required_attributes[path_id.to_s] = path_id_value unless path_id_value.nil?
|
390
436
|
end
|
391
437
|
|
392
|
-
|
438
|
+
required_attributes
|
393
439
|
end
|
394
440
|
|
395
441
|
sig { returns(Symbol) }
|
@@ -409,6 +455,18 @@ module ShopifyAPI
|
|
409
455
|
path
|
410
456
|
end
|
411
457
|
|
458
|
+
sig { params(method: Symbol).returns(T.nilable(T::Array[Symbol])) }
|
459
|
+
def deduce_path_ids(method)
|
460
|
+
path_ids = self.class.get_path_ids(http_method: method, operation: method)
|
461
|
+
|
462
|
+
if path_ids.nil?
|
463
|
+
method = method == :post ? :put : :post
|
464
|
+
path_ids = self.class.get_path_ids(http_method: method, operation: method)
|
465
|
+
end
|
466
|
+
|
467
|
+
path_ids
|
468
|
+
end
|
469
|
+
|
412
470
|
sig { params(hash: T::Hash[T.any(String, Symbol), T.untyped]).returns(T::Hash[String, String]) }
|
413
471
|
def deep_stringify_keys(hash)
|
414
472
|
hash.each_with_object({}) do |(key, value), result|
|
@@ -440,7 +498,7 @@ module ShopifyAPI
|
|
440
498
|
sig do
|
441
499
|
params(
|
442
500
|
element: T.nilable(T.any(T::Hash[String, T.untyped], ShopifyAPI::Rest::Base)),
|
443
|
-
attribute_class: Class,
|
501
|
+
attribute_class: T::Class[T.anything],
|
444
502
|
saving: T::Boolean,
|
445
503
|
).returns(T.nilable(T::Hash[String, T.untyped]))
|
446
504
|
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "hash_diff"
|
5
|
+
|
6
|
+
module ShopifyAPI
|
7
|
+
module Utils
|
8
|
+
module AttributesComparator
|
9
|
+
class << self
|
10
|
+
extend T::Sig
|
11
|
+
|
12
|
+
sig do
|
13
|
+
params(
|
14
|
+
original_attributes: T::Hash[String, T.untyped],
|
15
|
+
updated_attributes: T::Hash[String, T.untyped],
|
16
|
+
).returns(T::Hash[String, T.untyped])
|
17
|
+
end
|
18
|
+
def compare(original_attributes, updated_attributes)
|
19
|
+
attributes_diff = HashDiff::Comparison.new(
|
20
|
+
original_attributes,
|
21
|
+
updated_attributes,
|
22
|
+
).left_diff
|
23
|
+
|
24
|
+
update_value = build_update_value(
|
25
|
+
attributes_diff,
|
26
|
+
reference_values: updated_attributes,
|
27
|
+
)
|
28
|
+
|
29
|
+
update_value
|
30
|
+
end
|
31
|
+
|
32
|
+
sig do
|
33
|
+
params(
|
34
|
+
diff: T::Hash[String, T.untyped],
|
35
|
+
path: T::Array[String],
|
36
|
+
reference_values: T::Hash[String, T.untyped],
|
37
|
+
).returns(T::Hash[String, T.untyped])
|
38
|
+
end
|
39
|
+
def build_update_value(diff, path: [], reference_values: {})
|
40
|
+
new_hash = {}
|
41
|
+
|
42
|
+
diff.each do |key, value|
|
43
|
+
current_path = path + [key.to_s]
|
44
|
+
|
45
|
+
if value.is_a?(Hash)
|
46
|
+
has_numbered_key = value.keys.any? { |k| k.is_a?(Integer) }
|
47
|
+
ref_value = T.unsafe(reference_values).dig(*current_path)
|
48
|
+
|
49
|
+
if has_numbered_key && ref_value.is_a?(Array)
|
50
|
+
new_hash[key] = ref_value
|
51
|
+
else
|
52
|
+
new_value = build_update_value(value, path: current_path, reference_values: reference_values)
|
53
|
+
|
54
|
+
# Only add to new_hash if the user intentionally updates
|
55
|
+
# to empty value like `{}` or `[]`. For example:
|
56
|
+
#
|
57
|
+
# original = { "a" => { "foo" => 1 } }
|
58
|
+
# updated = { "a" => {} }
|
59
|
+
# diff = { "a" => { "foo" => HashDiff::NO_VALUE } }
|
60
|
+
# key = "a", new_value = {}, ref_value = {}
|
61
|
+
# new_hash = { "a" => {} }
|
62
|
+
#
|
63
|
+
# In addition, we omit cases where after removing `HashDiff::NO_VALUE`
|
64
|
+
# we only have `{}` left. For example:
|
65
|
+
#
|
66
|
+
# original = { "a" => { "foo" => 1, "bar" => 2} }
|
67
|
+
# updated = { "a" => { "foo" => 1 } }
|
68
|
+
# diff = { "a" => { "bar" => HashDiff::NO_VALUE } }
|
69
|
+
# key = "a", new_value = {}, ref_value = { "foo" => 1 }
|
70
|
+
# new_hash = {}
|
71
|
+
#
|
72
|
+
# new_hash is empty because nothing changes
|
73
|
+
new_hash[key] = new_value if !new_value.empty? || ref_value.empty?
|
74
|
+
end
|
75
|
+
elsif value != HashDiff::NO_VALUE
|
76
|
+
new_hash[key] = value
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
new_hash
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -27,7 +27,7 @@ module ShopifyAPI
|
|
27
27
|
def validate_signature(verifiable_query, secret)
|
28
28
|
received_signature = verifiable_query.hmac
|
29
29
|
computed_signature = compute_signature(verifiable_query.to_signable_string, secret)
|
30
|
-
OpenSSL.secure_compare(computed_signature, received_signature)
|
30
|
+
OpenSSL.secure_compare(computed_signature, T.must(received_signature))
|
31
31
|
end
|
32
32
|
|
33
33
|
sig { params(signable_string: String, secret: String).returns(String) }
|
data/lib/shopify_api/version.rb
CHANGED
@@ -3,13 +3,36 @@
|
|
3
3
|
|
4
4
|
module ShopifyAPI
|
5
5
|
module Webhooks
|
6
|
+
class WebhookMetadata < T::Struct
|
7
|
+
const :topic, String
|
8
|
+
const :shop, String
|
9
|
+
const :body, T::Hash[String, T.untyped]
|
10
|
+
const :api_version, String
|
11
|
+
const :webhook_id, String
|
12
|
+
end
|
13
|
+
|
6
14
|
module Handler
|
15
|
+
include Kernel
|
7
16
|
extend T::Sig
|
8
17
|
extend T::Helpers
|
9
18
|
interface!
|
10
19
|
|
11
|
-
sig
|
20
|
+
sig do
|
21
|
+
abstract.params(topic: String, shop: String, body: T::Hash[String, T.untyped]).void
|
22
|
+
end
|
12
23
|
def handle(topic:, shop:, body:); end
|
13
24
|
end
|
25
|
+
|
26
|
+
module WebhookHandler
|
27
|
+
include Kernel
|
28
|
+
extend T::Sig
|
29
|
+
extend T::Helpers
|
30
|
+
interface!
|
31
|
+
|
32
|
+
sig do
|
33
|
+
abstract.params(data: WebhookMetadata).void
|
34
|
+
end
|
35
|
+
def handle(data:); end
|
36
|
+
end
|
14
37
|
end
|
15
38
|
end
|
@@ -13,7 +13,7 @@ module ShopifyAPI
|
|
13
13
|
sig { returns(String) }
|
14
14
|
attr_reader :topic
|
15
15
|
|
16
|
-
sig { returns(T.nilable(Handler)) }
|
16
|
+
sig { returns(T.nilable(T.any(Handler, WebhookHandler))) }
|
17
17
|
attr_reader :handler
|
18
18
|
|
19
19
|
sig { returns(T.nilable(T::Array[String])) }
|
@@ -23,7 +23,7 @@ module ShopifyAPI
|
|
23
23
|
attr_reader :metafield_namespaces
|
24
24
|
|
25
25
|
sig do
|
26
|
-
params(topic: String, path: String, handler: T.nilable(Handler),
|
26
|
+
params(topic: String, path: String, handler: T.nilable(T.any(Handler, WebhookHandler)),
|
27
27
|
fields: T.nilable(T.any(String, T::Array[String])),
|
28
28
|
metafield_namespaces: T.nilable(T::Array[String])).void
|
29
29
|
end
|
@@ -17,7 +17,7 @@ module ShopifyAPI
|
|
17
17
|
params(topic: String,
|
18
18
|
delivery_method: Symbol,
|
19
19
|
path: String,
|
20
|
-
handler: T.nilable(Handler),
|
20
|
+
handler: T.nilable(T.any(Handler, WebhookHandler)),
|
21
21
|
fields: T.nilable(T.any(String, T::Array[String])),
|
22
22
|
metafield_namespaces: T.nilable(T::Array[String])).void
|
23
23
|
end
|
@@ -193,7 +193,18 @@ module ShopifyAPI
|
|
193
193
|
raise Errors::NoWebhookHandler, "No webhook handler found for topic: #{request.topic}."
|
194
194
|
end
|
195
195
|
|
196
|
-
handler.
|
196
|
+
if handler.is_a?(WebhookHandler)
|
197
|
+
handler.handle(data: WebhookMetadata.new(topic: request.topic, shop: request.shop,
|
198
|
+
body: request.parsed_body, api_version: request.api_version, webhook_id: request.webhook_id))
|
199
|
+
else
|
200
|
+
handler.handle(topic: request.topic, shop: request.shop, body: request.parsed_body)
|
201
|
+
warning = <<~WARNING
|
202
|
+
DEPRECATED: Use ShopifyAPI::Webhooks::WebhookHandler#handle instead of
|
203
|
+
ShopifyAPI::Webhooks::Handler#handle.
|
204
|
+
https://github.com/Shopify/shopify-api-ruby/blob/main/docs/usage/webhooks.md#create-a-webhook-handler
|
205
|
+
WARNING
|
206
|
+
ShopifyAPI::Logger.deprecated(warning, "15.0.0")
|
207
|
+
end
|
197
208
|
end
|
198
209
|
|
199
210
|
private
|
@@ -22,6 +22,16 @@ module ShopifyAPI
|
|
22
22
|
T.cast(@headers["x-shopify-shop-domain"], String)
|
23
23
|
end
|
24
24
|
|
25
|
+
sig { returns(String) }
|
26
|
+
def api_version
|
27
|
+
T.cast(@headers["x-shopify-api-version"], String)
|
28
|
+
end
|
29
|
+
|
30
|
+
sig { returns(String) }
|
31
|
+
def webhook_id
|
32
|
+
T.cast(@headers["x-shopify-webhook-id"], String)
|
33
|
+
end
|
34
|
+
|
25
35
|
sig { override.returns(String) }
|
26
36
|
def to_signable_string
|
27
37
|
@raw_body
|
data/lib/shopify_api.rb
CHANGED
@@ -16,6 +16,7 @@ require "concurrent"
|
|
16
16
|
|
17
17
|
require_relative "shopify_api/inflector"
|
18
18
|
require_relative "shopify_api/admin_versions"
|
19
|
+
require_relative "shopify_api/webhooks/handler"
|
19
20
|
|
20
21
|
loader = Zeitwerk::Loader.for_gem
|
21
22
|
loader.inflector = ShopifyAPI::Inflector.new(__FILE__)
|
data/shopify_api.gemspec
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: shopify_api
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 14.0.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Shopify
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
11
|
+
date: 2024-03-12 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -276,6 +276,7 @@ files:
|
|
276
276
|
- BREAKING_CHANGES_FOR_OLDER_VERSIONS.md
|
277
277
|
- BREAKING_CHANGES_FOR_V10.md
|
278
278
|
- CHANGELOG.md
|
279
|
+
- CODE_OF_CONDUCT.md
|
279
280
|
- CONTRIBUTING.md
|
280
281
|
- Gemfile
|
281
282
|
- Gemfile.lock
|
@@ -302,9 +303,11 @@ files:
|
|
302
303
|
- lib/shopify_api/auth/auth_scopes.rb
|
303
304
|
- lib/shopify_api/auth/jwt_payload.rb
|
304
305
|
- lib/shopify_api/auth/oauth.rb
|
306
|
+
- lib/shopify_api/auth/oauth/access_token_response.rb
|
305
307
|
- lib/shopify_api/auth/oauth/auth_query.rb
|
306
308
|
- lib/shopify_api/auth/oauth/session_cookie.rb
|
307
309
|
- lib/shopify_api/auth/session.rb
|
310
|
+
- lib/shopify_api/auth/token_exchange.rb
|
308
311
|
- lib/shopify_api/clients/graphql/admin.rb
|
309
312
|
- lib/shopify_api/clients/graphql/client.rb
|
310
313
|
- lib/shopify_api/clients/graphql/storefront.rb
|
@@ -938,6 +941,7 @@ files:
|
|
938
941
|
- lib/shopify_api/rest/resources/2024_01/user.rb
|
939
942
|
- lib/shopify_api/rest/resources/2024_01/variant.rb
|
940
943
|
- lib/shopify_api/rest/resources/2024_01/webhook.rb
|
944
|
+
- lib/shopify_api/utils/attributes_comparator.rb
|
941
945
|
- lib/shopify_api/utils/graphql_proxy.rb
|
942
946
|
- lib/shopify_api/utils/hmac_validator.rb
|
943
947
|
- lib/shopify_api/utils/http_utils.rb
|
@@ -1022,14 +1026,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
1022
1026
|
requirements:
|
1023
1027
|
- - ">="
|
1024
1028
|
- !ruby/object:Gem::Version
|
1025
|
-
version: '
|
1029
|
+
version: '3.0'
|
1026
1030
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
1027
1031
|
requirements:
|
1028
1032
|
- - ">="
|
1029
1033
|
- !ruby/object:Gem::Version
|
1030
1034
|
version: '0'
|
1031
1035
|
requirements: []
|
1032
|
-
rubygems_version: 3.5.
|
1036
|
+
rubygems_version: 3.5.6
|
1033
1037
|
signing_key:
|
1034
1038
|
specification_version: 4
|
1035
1039
|
summary: The gem for accessing the Shopify API
|