spree_api 5.4.0.beta3 → 5.4.0.beta5
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 +4 -4
- data/Rakefile +4 -4
- data/app/controllers/concerns/spree/api/v3/api_key_authentication.rb +1 -1
- data/app/controllers/concerns/spree/api/v3/error_handler.rb +14 -2
- data/app/controllers/concerns/spree/api/v3/http_caching.rb +15 -7
- data/app/controllers/concerns/spree/api/v3/idempotent.rb +82 -0
- data/app/controllers/concerns/spree/api/v3/jwt_authentication.rb +4 -1
- data/app/controllers/concerns/spree/api/v3/order_concern.rb +1 -6
- data/app/controllers/concerns/spree/api/v3/order_lock.rb +42 -0
- data/app/controllers/concerns/spree/api/v3/rate_limit_headers.rb +31 -0
- data/app/controllers/concerns/spree/api/v3/resource_serializer.rb +37 -9
- data/app/controllers/concerns/spree/api/v3/security_headers.rb +4 -0
- data/app/controllers/spree/api/v3/admin/base_controller.rb +28 -0
- data/app/controllers/spree/api/v3/admin/resource_controller.rb +28 -0
- data/app/controllers/spree/api/v3/base_controller.rb +20 -1
- data/app/controllers/spree/api/v3/resource_controller.rb +42 -4
- data/app/controllers/spree/api/v3/store/auth_controller.rb +4 -24
- data/app/controllers/spree/api/v3/store/base_controller.rb +0 -11
- data/app/controllers/spree/api/v3/store/cart_controller.rb +6 -2
- data/app/controllers/spree/api/v3/store/categories/products_controller.rb +37 -0
- data/app/controllers/spree/api/v3/store/{taxons_controller.rb → categories_controller.rb} +8 -6
- data/app/controllers/spree/api/v3/store/countries_controller.rb +6 -0
- data/app/controllers/spree/api/v3/store/currencies_controller.rb +4 -0
- data/app/controllers/spree/api/v3/store/customers_controller.rb +102 -0
- data/app/controllers/spree/api/v3/store/locales_controller.rb +4 -0
- data/app/controllers/spree/api/v3/store/markets/countries_controller.rb +45 -0
- data/app/controllers/spree/api/v3/store/markets_controller.rb +50 -0
- data/app/controllers/spree/api/v3/store/orders/coupon_codes_controller.rb +18 -13
- data/app/controllers/spree/api/v3/store/orders/line_items_controller.rb +43 -36
- data/app/controllers/spree/api/v3/store/orders/shipments_controller.rb +9 -6
- data/app/controllers/spree/api/v3/store/orders/store_credits_controller.rb +18 -13
- data/app/controllers/spree/api/v3/store/orders_controller.rb +39 -29
- data/app/controllers/spree/api/v3/store/products/filters_controller.rb +9 -8
- data/app/controllers/spree/api/v3/store/products_controller.rb +29 -17
- data/app/jobs/spree/webhook_delivery_job.rb +4 -1
- data/app/serializers/spree/api/v3/address_serializer.rb +3 -2
- data/app/serializers/spree/api/v3/admin/address_serializer.rb +24 -0
- data/app/serializers/spree/api/v3/admin/adjustment_serializer.rb +36 -0
- data/app/serializers/spree/api/v3/admin/admin_user_serializer.rb +15 -0
- data/app/serializers/spree/api/v3/admin/asset_serializer.rb +10 -0
- data/app/serializers/spree/api/v3/admin/category_serializer.rb +33 -0
- data/app/serializers/spree/api/v3/admin/credit_card_serializer.rb +22 -0
- data/app/serializers/spree/api/v3/admin/customer_serializer.rb +8 -6
- data/app/serializers/spree/api/v3/admin/digital_link_serializer.rb +10 -0
- data/app/serializers/spree/api/v3/admin/image_serializer.rb +10 -0
- data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +36 -1
- data/app/serializers/spree/api/v3/admin/option_type_serializer.rb +13 -0
- data/app/serializers/spree/api/v3/admin/option_value_serializer.rb +13 -0
- data/app/serializers/spree/api/v3/admin/order_promotion_serializer.rb +10 -0
- data/app/serializers/spree/api/v3/admin/order_serializer.rb +47 -6
- data/app/serializers/spree/api/v3/admin/payment_method_serializer.rb +14 -0
- data/app/serializers/spree/api/v3/admin/payment_serializer.rb +56 -0
- data/app/serializers/spree/api/v3/admin/payment_source_serializer.rb +10 -0
- data/app/serializers/spree/api/v3/admin/product_serializer.rb +23 -6
- data/app/serializers/spree/api/v3/admin/refund_serializer.rb +21 -0
- data/app/serializers/spree/api/v3/admin/reimbursement_serializer.rb +13 -0
- data/app/serializers/spree/api/v3/admin/return_authorization_serializer.rb +17 -0
- data/app/serializers/spree/api/v3/admin/shipment_serializer.rb +44 -0
- data/app/serializers/spree/api/v3/admin/shipping_category_serializer.rb +14 -0
- data/app/serializers/spree/api/v3/admin/shipping_method_serializer.rb +11 -0
- data/app/serializers/spree/api/v3/admin/shipping_rate_serializer.rb +11 -0
- data/app/serializers/spree/api/v3/admin/stock_item_serializer.rb +17 -0
- data/app/serializers/spree/api/v3/admin/stock_location_serializer.rb +15 -0
- data/app/serializers/spree/api/v3/admin/store_credit_serializer.rb +27 -0
- data/app/serializers/spree/api/v3/admin/tax_category_serializer.rb +15 -0
- data/app/serializers/spree/api/v3/admin/variant_serializer.rb +11 -14
- data/app/serializers/spree/api/v3/base_serializer.rb +28 -11
- data/app/serializers/spree/api/v3/category_serializer.rb +71 -0
- data/app/serializers/spree/api/v3/country_serializer.rb +7 -17
- data/app/serializers/spree/api/v3/customer_serializer.rb +2 -1
- data/app/serializers/spree/api/v3/gift_card_serializer.rb +5 -21
- data/app/serializers/spree/api/v3/market_serializer.rb +23 -0
- data/app/serializers/spree/api/v3/order_serializer.rb +4 -4
- data/app/serializers/spree/api/v3/payment_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/product_serializer.rb +11 -11
- data/app/serializers/spree/api/v3/shipping_category_serializer.rb +11 -0
- data/app/serializers/spree/api/v3/shipping_rate_serializer.rb +2 -6
- data/app/serializers/spree/api/v3/tax_category_serializer.rb +11 -0
- data/app/serializers/spree/api/v3/variant_serializer.rb +4 -4
- data/app/serializers/spree/api/v3/wishlist_serializer.rb +1 -1
- data/app/services/spree/api/v3/filters_aggregator.rb +36 -43
- data/app/services/spree/webhooks/deliver_webhook.rb +23 -17
- data/app/subscribers/spree/webhook_event_subscriber.rb +1 -1
- data/config/initializers/alba.rb +1 -1
- data/config/initializers/typelizer.rb +26 -16
- data/config/locales/en.yml +1 -0
- data/config/routes.rb +17 -9
- data/lib/spree/api/configuration.rb +3 -1
- data/lib/spree/api/dependencies.rb +29 -5
- data/lib/spree/api/engine.rb +15 -0
- data/lib/spree/api/openapi/schema_helper.rb +27 -7
- data/lib/spree/api/testing_support/v3/base.rb +24 -1
- metadata +47 -19
- data/app/controllers/spree/api/v3/store/customer/account_controller.rb +0 -38
- data/app/controllers/spree/api/v3/store/stores_controller.rb +0 -26
- data/app/controllers/spree/api/v3/store/taxonomies_controller.rb +0 -19
- data/app/controllers/spree/api/v3/store/taxons/products_controller.rb +0 -37
- data/app/serializers/spree/api/v3/admin/taxon_serializer.rb +0 -20
- data/app/serializers/spree/api/v3/admin/taxonomy_serializer.rb +0 -15
- data/app/serializers/spree/api/v3/store_serializer.rb +0 -38
- data/app/serializers/spree/api/v3/taxon_serializer.rb +0 -78
- data/app/serializers/spree/api/v3/taxonomy_serializer.rb +0 -33
- data/app/services/spree/api/v3/orders/update.rb +0 -105
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 03df105b93ed4bea7174ff452d0b35024cb242b032bf2e546d4bdabab30197d4
|
|
4
|
+
data.tar.gz: 4259a95154f7fbf1a4921a2b7985659b74a1466395e9b72bc6b8bfc5190105ce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8b301d54049c9e83c1136596f2f2f30f520ddee5f71e1debc743671e3c68e55521f3cff533841e0894191a1a8893b3e871548f8cb4e527db6c877e3d68c4d4f5
|
|
7
|
+
data.tar.gz: aefe0034f78db8beadd83634e9ad0e58822101086af702ee58a7536d52e0e00b7306dca1b5a1db6b9e31e6503dce18b677a0e0d74d315e3a18a2e153ff7fc010
|
data/Rakefile
CHANGED
|
@@ -31,7 +31,7 @@ namespace :rswag do
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
namespace :typelizer do
|
|
34
|
-
desc 'Generate TypeScript types
|
|
34
|
+
desc 'Generate TypeScript types for both Store and Admin SDKs'
|
|
35
35
|
task :generate do
|
|
36
36
|
ENV['RAILS_ENV'] ||= 'test'
|
|
37
37
|
ENV['DISABLE_TYPELIZER'] = 'false'
|
|
@@ -43,10 +43,10 @@ namespace :typelizer do
|
|
|
43
43
|
Rails.autoloaders.main.eager_load_dir(serializers_path)
|
|
44
44
|
|
|
45
45
|
require 'typelizer/generator'
|
|
46
|
-
serializers = Typelizer::Generator.call(force: true)
|
|
47
46
|
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
# Writer config is in config/initializers/typelizer.rb
|
|
48
|
+
Typelizer::Generator.call(force: true)
|
|
49
|
+
puts "Generated types → packages/sdk/src/types/generated/ & packages/admin-sdk/src/types/generated/"
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
desc 'Clean and regenerate TypeScript types'
|
|
@@ -12,6 +12,7 @@ module Spree
|
|
|
12
12
|
access_denied: 'access_denied',
|
|
13
13
|
invalid_token: 'invalid_token',
|
|
14
14
|
invalid_provider: 'invalid_provider',
|
|
15
|
+
current_password_invalid: 'current_password_invalid',
|
|
15
16
|
|
|
16
17
|
# Resource errors
|
|
17
18
|
record_not_found: 'record_not_found',
|
|
@@ -23,6 +24,7 @@ module Spree
|
|
|
23
24
|
order_cannot_transition: 'order_cannot_transition',
|
|
24
25
|
order_empty: 'order_empty',
|
|
25
26
|
order_invalid_state: 'order_invalid_state',
|
|
27
|
+
order_already_updated: 'order_already_updated',
|
|
26
28
|
|
|
27
29
|
# Line item errors
|
|
28
30
|
line_item_not_found: 'line_item_not_found',
|
|
@@ -49,6 +51,9 @@ module Spree
|
|
|
49
51
|
# Rate limiting errors
|
|
50
52
|
rate_limit_exceeded: 'rate_limit_exceeded',
|
|
51
53
|
|
|
54
|
+
# Idempotency errors
|
|
55
|
+
idempotency_key_reused: 'idempotency_key_reused',
|
|
56
|
+
|
|
52
57
|
# Request errors
|
|
53
58
|
request_too_large: 'request_too_large',
|
|
54
59
|
|
|
@@ -192,6 +197,7 @@ module Spree
|
|
|
192
197
|
)
|
|
193
198
|
end
|
|
194
199
|
|
|
200
|
+
|
|
195
201
|
private
|
|
196
202
|
|
|
197
203
|
# Format validation errors for details field
|
|
@@ -234,9 +240,15 @@ module Spree
|
|
|
234
240
|
end
|
|
235
241
|
|
|
236
242
|
# Generate human-readable not found message
|
|
243
|
+
# Uses the exception's own message when it contains useful context (e.g. prefixed ID),
|
|
244
|
+
# otherwise falls back to a generic "[Model] not found" translation.
|
|
237
245
|
def generate_not_found_message(exception)
|
|
238
|
-
|
|
239
|
-
|
|
246
|
+
if exception.id.present?
|
|
247
|
+
exception.message
|
|
248
|
+
else
|
|
249
|
+
model_name = extract_model_name(exception)
|
|
250
|
+
Spree.t(:record_not_found, scope: 'api', model: model_name&.humanize || 'record')
|
|
251
|
+
end
|
|
240
252
|
end
|
|
241
253
|
|
|
242
254
|
# Extract clean model name from exception
|
|
@@ -12,7 +12,7 @@ module Spree
|
|
|
12
12
|
extend ActiveSupport::Concern
|
|
13
13
|
|
|
14
14
|
included do
|
|
15
|
-
|
|
15
|
+
after_action :set_vary_headers
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
protected
|
|
@@ -69,15 +69,23 @@ module Spree
|
|
|
69
69
|
private
|
|
70
70
|
|
|
71
71
|
# Build a cache key for a collection
|
|
72
|
-
# Includes: query params, pagination,
|
|
73
|
-
# Strips order to avoid PostgreSQL errors with DISTINCT + subquery ORDER BY expressions
|
|
72
|
+
# Includes: latest updated_at, total count, query params, pagination, expand, currency, locale
|
|
74
73
|
def collection_cache_key(collection)
|
|
74
|
+
# For ActiveRecord collections use updated_at, for plain arrays use store's updated_at as proxy
|
|
75
|
+
latest_updated_at = if collection.first.respond_to?(:updated_at)
|
|
76
|
+
collection.map(&:updated_at).max&.to_i
|
|
77
|
+
else
|
|
78
|
+
current_store&.updated_at&.to_i
|
|
79
|
+
end
|
|
80
|
+
|
|
75
81
|
parts = [
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
params[:
|
|
82
|
+
latest_updated_at,
|
|
83
|
+
@pagy&.count || collection.size,
|
|
84
|
+
params[:expand],
|
|
85
|
+
params[:fields],
|
|
86
|
+
params[:q]&.to_json,
|
|
79
87
|
params[:page],
|
|
80
|
-
params[:
|
|
88
|
+
params[:limit],
|
|
81
89
|
current_currency,
|
|
82
90
|
current_locale
|
|
83
91
|
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V3
|
|
4
|
+
module Idempotent
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
IDEMPOTENCY_TTL = 24.hours
|
|
8
|
+
IDEMPOTENCY_HEADER = 'Idempotency-Key'
|
|
9
|
+
MAX_KEY_LENGTH = 255
|
|
10
|
+
|
|
11
|
+
MUTATING_METHODS = %w[POST PUT PATCH DELETE].freeze
|
|
12
|
+
|
|
13
|
+
included do
|
|
14
|
+
around_action :check_idempotency, if: :mutating_request?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def check_idempotency
|
|
20
|
+
key = request.headers[IDEMPOTENCY_HEADER]
|
|
21
|
+
return yield if key.blank?
|
|
22
|
+
|
|
23
|
+
if key.length > MAX_KEY_LENGTH
|
|
24
|
+
render_error(
|
|
25
|
+
code: ErrorHandler::ERROR_CODES[:invalid_request],
|
|
26
|
+
message: "Idempotency-Key must be #{MAX_KEY_LENGTH} characters or less.",
|
|
27
|
+
status: :bad_request
|
|
28
|
+
)
|
|
29
|
+
return
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
cache_key = idempotency_cache_key(key)
|
|
33
|
+
cached = Rails.cache.read(cache_key)
|
|
34
|
+
|
|
35
|
+
if cached
|
|
36
|
+
if cached[:fingerprint] != request_fingerprint
|
|
37
|
+
render_error(
|
|
38
|
+
code: ErrorHandler::ERROR_CODES[:idempotency_key_reused],
|
|
39
|
+
message: Spree.t(:idempotency_key_reused),
|
|
40
|
+
status: :unprocessable_content
|
|
41
|
+
)
|
|
42
|
+
return
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
self.response_body = cached[:body]
|
|
46
|
+
self.status = cached[:status]
|
|
47
|
+
response.content_type = cached[:content_type] if cached[:content_type]
|
|
48
|
+
response.headers['Idempotent-Replayed'] = 'true'
|
|
49
|
+
return
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
yield
|
|
53
|
+
|
|
54
|
+
# Cache 2xx and 4xx responses, skip 5xx (transient server errors should be retryable)
|
|
55
|
+
if response.status < 500
|
|
56
|
+
Rails.cache.write(cache_key, {
|
|
57
|
+
body: response.body,
|
|
58
|
+
status: response.status,
|
|
59
|
+
content_type: response.content_type,
|
|
60
|
+
fingerprint: request_fingerprint
|
|
61
|
+
}, expires_in: IDEMPOTENCY_TTL)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def mutating_request?
|
|
66
|
+
MUTATING_METHODS.include?(request.method)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def idempotency_cache_key(key)
|
|
70
|
+
owner_id = request.headers['X-Spree-Api-Key'].presence ||
|
|
71
|
+
spree_current_user&.id ||
|
|
72
|
+
request.remote_ip
|
|
73
|
+
"spree:idempotency:#{Digest::SHA256.hexdigest(owner_id.to_s)}:#{Digest::SHA256.hexdigest(key)}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def request_fingerprint
|
|
77
|
+
Digest::SHA256.hexdigest("#{request.method}:#{request.path}:#{request.raw_post}")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -82,7 +82,10 @@ module Spree
|
|
|
82
82
|
end
|
|
83
83
|
|
|
84
84
|
def jwt_secret
|
|
85
|
-
|
|
85
|
+
Spree::Api::Config[:jwt_secret_key].presence ||
|
|
86
|
+
Rails.application.credentials.jwt_secret_key ||
|
|
87
|
+
ENV['JWT_SECRET_KEY'] ||
|
|
88
|
+
Rails.application.secret_key_base
|
|
86
89
|
end
|
|
87
90
|
|
|
88
91
|
def jwt_expiration
|
|
@@ -38,12 +38,7 @@ module Spree
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def order_token
|
|
41
|
-
|
|
42
|
-
header = request.headers['x-spree-order-token']
|
|
43
|
-
return header if header.present?
|
|
44
|
-
|
|
45
|
-
# Fallback to query params (support both token and order_token)
|
|
46
|
-
params[:order_token].presence || params[:token]
|
|
41
|
+
request.headers['x-spree-order-token']
|
|
47
42
|
end
|
|
48
43
|
end
|
|
49
44
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V3
|
|
4
|
+
module OrderLock
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
rescue_from ActiveRecord::Deadlocked, with: :handle_order_lock_conflict
|
|
9
|
+
rescue_from ActiveRecord::LockWaitTimeout, with: :handle_order_lock_conflict
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def with_order_lock
|
|
15
|
+
order = @order || @parent
|
|
16
|
+
|
|
17
|
+
order.with_lock do
|
|
18
|
+
# Persist increment within the transaction so reloads inside yield see the new version
|
|
19
|
+
new_version = order.state_lock_version + 1
|
|
20
|
+
order.update_column(:state_lock_version, new_version)
|
|
21
|
+
|
|
22
|
+
yield
|
|
23
|
+
|
|
24
|
+
if performed? && response.status >= 400
|
|
25
|
+
# Operation failed — revert the increment
|
|
26
|
+
order.update_column(:state_lock_version, new_version - 1)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def handle_order_lock_conflict(exception)
|
|
32
|
+
Rails.error.report(exception, context: { order_id: (@order || @parent)&.id }, source: 'spree.api.v3')
|
|
33
|
+
render_error(
|
|
34
|
+
code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:order_already_updated],
|
|
35
|
+
message: Spree.t(:order_already_updated),
|
|
36
|
+
status: :conflict
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V3
|
|
4
|
+
# Sets X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After headers
|
|
5
|
+
# on every Store API response by reading the same cache counter that
|
|
6
|
+
# Rails' built-in `rate_limit` writes to.
|
|
7
|
+
module RateLimitHeaders
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
after_action :set_rate_limit_headers
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def set_rate_limit_headers
|
|
17
|
+
limit = Spree::Api::Config[:rate_limit_per_key]
|
|
18
|
+
by = request.headers['X-Spree-Api-Key'] || request.remote_ip
|
|
19
|
+
cache_key = ['rate-limit', controller_path, by].compact.join(':')
|
|
20
|
+
count = Rails.cache.read(cache_key)
|
|
21
|
+
|
|
22
|
+
return if count.nil?
|
|
23
|
+
|
|
24
|
+
response.headers['X-RateLimit-Limit'] = limit.to_s
|
|
25
|
+
response.headers['X-RateLimit-Remaining'] = [limit - count.to_i, 0].max.to_s
|
|
26
|
+
response.headers['Retry-After'] = Spree::Api::Config[:rate_limit_window].to_s if count.to_i >= limit
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -8,12 +8,16 @@ module Spree
|
|
|
8
8
|
|
|
9
9
|
# Serialize a single resource
|
|
10
10
|
def serialize_resource(resource)
|
|
11
|
-
serializer_class.new(resource, params: serializer_params).to_h
|
|
11
|
+
hash = serializer_class.new(resource, params: serializer_params).to_h
|
|
12
|
+
filter_fields(hash)
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
# Serialize a collection of resources
|
|
15
16
|
def serialize_collection(collection)
|
|
16
|
-
collection.map
|
|
17
|
+
collection.map do |item|
|
|
18
|
+
hash = serializer_class.new(item, params: serializer_params).to_h
|
|
19
|
+
filter_fields(hash)
|
|
20
|
+
end
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
# Params passed to serializers
|
|
@@ -23,17 +27,41 @@ module Spree
|
|
|
23
27
|
store: current_store,
|
|
24
28
|
user: current_user,
|
|
25
29
|
locale: current_locale,
|
|
26
|
-
|
|
30
|
+
expand: expand_list
|
|
27
31
|
}
|
|
28
32
|
end
|
|
29
33
|
|
|
30
|
-
# Parse
|
|
31
|
-
# Supports: ?
|
|
32
|
-
def
|
|
33
|
-
|
|
34
|
-
return [] unless
|
|
34
|
+
# Parse expand parameter into list
|
|
35
|
+
# Supports: ?expand=variants,images
|
|
36
|
+
def expand_list
|
|
37
|
+
expand_param = params[:expand].presence
|
|
38
|
+
return [] unless expand_param
|
|
35
39
|
|
|
36
|
-
|
|
40
|
+
expand_param.to_s.split(',').map(&:strip)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Parse fields parameter into a Set for O(1) lookup.
|
|
44
|
+
# Returns nil when no fields param is present (return all fields).
|
|
45
|
+
# Supports: ?fields=name,slug,price
|
|
46
|
+
def fields_list
|
|
47
|
+
return @fields_list if defined?(@fields_list)
|
|
48
|
+
|
|
49
|
+
fields_param = params[:fields].presence
|
|
50
|
+
@fields_list = fields_param ? fields_param.to_s.split(',').map(&:strip).to_set : nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Filter serialized hash to only include requested fields.
|
|
54
|
+
# The 'id' field is always included. Expanded associations are always included.
|
|
55
|
+
def filter_fields(hash)
|
|
56
|
+
fields = fields_list
|
|
57
|
+
return hash unless fields
|
|
58
|
+
|
|
59
|
+
hash.select { |key, _| key == 'id' || fields.include?(key) || expanded_keys.include?(key) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Top-level expand keys (e.g., 'variants.images' → 'variants')
|
|
63
|
+
def expanded_keys
|
|
64
|
+
@expanded_keys ||= expand_list.map { |e| e.split('.').first }.to_set
|
|
37
65
|
end
|
|
38
66
|
end
|
|
39
67
|
end
|
|
@@ -13,6 +13,10 @@ module Spree
|
|
|
13
13
|
def set_security_headers
|
|
14
14
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
|
15
15
|
response.headers['X-Frame-Options'] = 'DENY'
|
|
16
|
+
response.headers['X-Request-Id'] = request.request_id
|
|
17
|
+
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
|
18
|
+
response.headers['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=()'
|
|
19
|
+
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' if request.ssl?
|
|
16
20
|
response.headers.delete('X-Powered-By')
|
|
17
21
|
response.headers.delete('Server')
|
|
18
22
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V3
|
|
4
|
+
module Admin
|
|
5
|
+
class BaseController < Spree::Api::V3::BaseController
|
|
6
|
+
# Require secret API key for all Admin API requests
|
|
7
|
+
before_action :authenticate_secret_key!
|
|
8
|
+
|
|
9
|
+
# Admin API responses must never be cached
|
|
10
|
+
after_action :set_no_store_cache
|
|
11
|
+
|
|
12
|
+
protected
|
|
13
|
+
|
|
14
|
+
# Override JWT audience to require admin tokens
|
|
15
|
+
def expected_audience
|
|
16
|
+
JWT_AUDIENCE_ADMIN
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def set_no_store_cache
|
|
22
|
+
response.headers['Cache-Control'] = 'private, no-store'
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V3
|
|
4
|
+
module Admin
|
|
5
|
+
class ResourceController < Spree::Api::V3::ResourceController
|
|
6
|
+
# Require secret API key for all Admin API requests
|
|
7
|
+
before_action :authenticate_secret_key!
|
|
8
|
+
|
|
9
|
+
# Admin API responses must never be cached
|
|
10
|
+
after_action :set_no_store_cache
|
|
11
|
+
|
|
12
|
+
protected
|
|
13
|
+
|
|
14
|
+
# Override JWT audience to require admin tokens
|
|
15
|
+
def expected_audience
|
|
16
|
+
JWT_AUDIENCE_ADMIN
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def set_no_store_cache
|
|
22
|
+
response.headers['Cache-Control'] = 'private, no-store'
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -10,11 +10,30 @@ module Spree
|
|
|
10
10
|
include Spree::Api::V3::JwtAuthentication
|
|
11
11
|
include Spree::Api::V3::ApiKeyAuthentication
|
|
12
12
|
include Spree::Api::V3::ErrorHandler
|
|
13
|
-
include Spree::Api::V3::HttpCaching
|
|
14
13
|
include Spree::Api::V3::SecurityHeaders
|
|
15
14
|
include Spree::Api::V3::ResourceSerializer
|
|
15
|
+
include Spree::Api::V3::RateLimitHeaders
|
|
16
|
+
include Spree::Api::V3::Idempotent
|
|
16
17
|
include Pagy::Method
|
|
17
18
|
|
|
19
|
+
RATE_LIMIT_RESPONSE = -> {
|
|
20
|
+
limit = Spree::Api::Config[:rate_limit_per_key]
|
|
21
|
+
window = Spree::Api::Config[:rate_limit_window]
|
|
22
|
+
body = { error: { code: 'rate_limit_exceeded', message: 'Too many requests. Please retry later.' } }
|
|
23
|
+
headers = {
|
|
24
|
+
'Content-Type' => 'application/json',
|
|
25
|
+
'Retry-After' => window.to_s,
|
|
26
|
+
'X-RateLimit-Limit' => limit.to_s,
|
|
27
|
+
'X-RateLimit-Remaining' => '0'
|
|
28
|
+
}
|
|
29
|
+
[429, headers, [body.to_json]]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
rate_limit to: Spree::Api::Config[:rate_limit_per_key], within: Spree::Api::Config[:rate_limit_window].seconds,
|
|
33
|
+
store: Rails.cache,
|
|
34
|
+
by: -> { request.headers['X-Spree-Api-Key'] || request.remote_ip },
|
|
35
|
+
with: RATE_LIMIT_RESPONSE
|
|
36
|
+
|
|
18
37
|
# Optional JWT authentication by default
|
|
19
38
|
before_action :authenticate_user
|
|
20
39
|
|
|
@@ -55,6 +55,16 @@ module Spree
|
|
|
55
55
|
|
|
56
56
|
protected
|
|
57
57
|
|
|
58
|
+
# No-op HTTP caching methods. Include Spree::Api::V3::HttpCaching
|
|
59
|
+
# in specific controllers to enable HTTP caching for their actions.
|
|
60
|
+
def cache_collection(_collection, **_options)
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def cache_resource(_resource, **_options)
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
|
|
58
68
|
# Override in subclass to set parent resource (e.g., @wishlist, @order)
|
|
59
69
|
# This runs before set_resource, allowing scope to use the parent
|
|
60
70
|
def set_parent
|
|
@@ -97,12 +107,14 @@ module Spree
|
|
|
97
107
|
preload_associations_lazily.
|
|
98
108
|
ransack(ransack_params)
|
|
99
109
|
result = @search.result(distinct: collection_distinct?)
|
|
110
|
+
pagy_options = { limit: limit, page: page }
|
|
100
111
|
result = apply_collection_sort(result)
|
|
101
|
-
@pagy, @collection = pagy(result,
|
|
112
|
+
@pagy, @collection = pagy(result, **pagy_options)
|
|
102
113
|
@collection
|
|
103
114
|
end
|
|
104
115
|
|
|
105
116
|
# Override in subclass to disable distinct (e.g., for custom sorting with computed columns)
|
|
117
|
+
# @return [Boolean] whether to apply distinct to the collection
|
|
106
118
|
def collection_distinct?
|
|
107
119
|
true
|
|
108
120
|
end
|
|
@@ -112,26 +124,52 @@ module Spree
|
|
|
112
124
|
collection
|
|
113
125
|
end
|
|
114
126
|
|
|
127
|
+
# Override in subclass to specify collection includes
|
|
128
|
+
# @return [Array<Symbol>] the includes to apply to the collection
|
|
115
129
|
def collection_includes
|
|
116
130
|
[]
|
|
117
131
|
end
|
|
118
132
|
|
|
119
|
-
# Ransack query parameters
|
|
133
|
+
# Ransack query parameters with sort translation.
|
|
134
|
+
# Translates `-field` notation (JSON:API standard) to Ransack `s` format.
|
|
135
|
+
# e.g., sort=-price,name → s=price desc,name asc
|
|
120
136
|
def ransack_params
|
|
121
|
-
params[:q] || {}
|
|
137
|
+
rp = params[:q]&.to_unsafe_h || params[:q] || {}
|
|
138
|
+
sort_value = sort_param
|
|
139
|
+
|
|
140
|
+
if sort_value.present?
|
|
141
|
+
rp = rp.dup unless rp.is_a?(Hash)
|
|
142
|
+
rp['s'] = sort_value.split(',').map { |field|
|
|
143
|
+
if field.start_with?('-')
|
|
144
|
+
"#{field[1..]} desc"
|
|
145
|
+
else
|
|
146
|
+
"#{field} asc"
|
|
147
|
+
end
|
|
148
|
+
}.join(',')
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
rp
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Sort parameter from the request
|
|
155
|
+
def sort_param
|
|
156
|
+
params[:sort]
|
|
122
157
|
end
|
|
123
158
|
|
|
124
159
|
# Pagination parameters
|
|
160
|
+
# @return [Integer] the current page number
|
|
125
161
|
def page
|
|
126
162
|
params[:page]&.to_i || 1
|
|
127
163
|
end
|
|
128
164
|
|
|
165
|
+
# @return [Integer] the number of items per page
|
|
129
166
|
def limit
|
|
130
|
-
limit_param = params[:
|
|
167
|
+
limit_param = params[:limit]&.to_i || 25
|
|
131
168
|
[limit_param, 100].min # Max 100 per page
|
|
132
169
|
end
|
|
133
170
|
|
|
134
171
|
# Metadata for collection responses
|
|
172
|
+
# @return [Hash] pagination metadata
|
|
135
173
|
def collection_meta(_collection)
|
|
136
174
|
return {} unless @pagy
|
|
137
175
|
|
|
@@ -4,12 +4,11 @@ module Spree
|
|
|
4
4
|
module Store
|
|
5
5
|
class AuthController < Store::BaseController
|
|
6
6
|
# Tighter rate limits for auth endpoints (per IP to prevent brute force)
|
|
7
|
-
rate_limit to: Spree::Api::Config[:rate_limit_login], within:
|
|
8
|
-
rate_limit to: Spree::Api::Config[:
|
|
9
|
-
rate_limit to: Spree::Api::Config[:
|
|
10
|
-
rate_limit to: Spree::Api::Config[:rate_limit_oauth], within: 1.minute, store: Rails.cache, only: :oauth_callback, with: RATE_LIMIT_RESPONSE
|
|
7
|
+
rate_limit to: Spree::Api::Config[:rate_limit_login], within: Spree::Api::Config[:rate_limit_window].seconds, store: Rails.cache, only: :create, with: RATE_LIMIT_RESPONSE
|
|
8
|
+
rate_limit to: Spree::Api::Config[:rate_limit_refresh], within: Spree::Api::Config[:rate_limit_window].seconds, store: Rails.cache, only: :refresh, with: RATE_LIMIT_RESPONSE
|
|
9
|
+
rate_limit to: Spree::Api::Config[:rate_limit_oauth], within: Spree::Api::Config[:rate_limit_window].seconds, store: Rails.cache, only: :oauth_callback, with: RATE_LIMIT_RESPONSE
|
|
11
10
|
|
|
12
|
-
skip_before_action :authenticate_user, only: [:create, :
|
|
11
|
+
skip_before_action :authenticate_user, only: [:create, :oauth_callback]
|
|
13
12
|
prepend_before_action :require_authentication!, only: [:refresh]
|
|
14
13
|
|
|
15
14
|
# POST /api/v3/store/auth/login
|
|
@@ -38,21 +37,6 @@ module Spree
|
|
|
38
37
|
end
|
|
39
38
|
end
|
|
40
39
|
|
|
41
|
-
# POST /api/v3/store/auth/register
|
|
42
|
-
def register
|
|
43
|
-
user = Spree.user_class.new(registration_params)
|
|
44
|
-
|
|
45
|
-
if user.save
|
|
46
|
-
token = generate_jwt(user)
|
|
47
|
-
render json: {
|
|
48
|
-
token: token,
|
|
49
|
-
user: user_serializer.new(user, params: serializer_params).to_h
|
|
50
|
-
}, status: :created
|
|
51
|
-
else
|
|
52
|
-
render_errors(user.errors)
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
|
|
56
40
|
# POST /api/v3/store/auth/refresh
|
|
57
41
|
def refresh
|
|
58
42
|
token = generate_jwt(current_user)
|
|
@@ -132,10 +116,6 @@ module Spree
|
|
|
132
116
|
strategy_class
|
|
133
117
|
end
|
|
134
118
|
|
|
135
|
-
def registration_params
|
|
136
|
-
params.permit(:email, :password, :password_confirmation, :first_name, :last_name)
|
|
137
|
-
end
|
|
138
|
-
|
|
139
119
|
def user_serializer
|
|
140
120
|
Spree.api.customer_serializer
|
|
141
121
|
end
|
|
@@ -3,17 +3,6 @@ module Spree
|
|
|
3
3
|
module V3
|
|
4
4
|
module Store
|
|
5
5
|
class BaseController < Spree::Api::V3::BaseController
|
|
6
|
-
RATE_LIMIT_RESPONSE = -> {
|
|
7
|
-
body = { error: { code: 'rate_limit_exceeded', message: 'Too many requests. Please retry later.' } }
|
|
8
|
-
[429, { 'Content-Type' => 'application/json', 'Retry-After' => '60' }, [body.to_json]]
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
# Global rate limit per publishable API key
|
|
12
|
-
rate_limit to: Spree::Api::Config[:rate_limit_per_key], within: 1.minute,
|
|
13
|
-
store: Rails.cache,
|
|
14
|
-
by: -> { request.headers['X-Spree-Api-Key'] || request.remote_ip },
|
|
15
|
-
with: RATE_LIMIT_RESPONSE
|
|
16
|
-
|
|
17
6
|
# Require publishable API key for all Store API requests
|
|
18
7
|
before_action :authenticate_api_key!
|
|
19
8
|
end
|
|
@@ -16,7 +16,8 @@ module Spree
|
|
|
16
16
|
store: current_store,
|
|
17
17
|
currency: current_currency,
|
|
18
18
|
locale: current_locale,
|
|
19
|
-
metadata: cart_params[:metadata] || {}
|
|
19
|
+
metadata: cart_params[:metadata] || {},
|
|
20
|
+
line_items: cart_params[:line_items] || []
|
|
20
21
|
)
|
|
21
22
|
|
|
22
23
|
if result.success?
|
|
@@ -61,7 +62,10 @@ module Spree
|
|
|
61
62
|
private
|
|
62
63
|
|
|
63
64
|
def cart_params
|
|
64
|
-
params.permit(
|
|
65
|
+
params.permit(
|
|
66
|
+
metadata: {},
|
|
67
|
+
line_items: [:variant_id, :quantity, { metadata: {}, options: {} }]
|
|
68
|
+
)
|
|
65
69
|
end
|
|
66
70
|
|
|
67
71
|
# Find incomplete cart by order token for associate action
|