spree_api 5.4.0.beta4 → 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 +12 -2
- data/app/controllers/concerns/spree/api/v3/http_caching.rb +10 -2
- 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/rate_limit_headers.rb +31 -0
- data/app/controllers/concerns/spree/api/v3/resource_serializer.rb +30 -2
- 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 +35 -3
- 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 +6 -0
- data/app/controllers/spree/api/v3/store/markets_controller.rb +8 -0
- data/app/controllers/spree/api/v3/store/orders_controller.rb +3 -2
- data/app/controllers/spree/api/v3/store/products/filters_controller.rb +9 -8
- data/app/controllers/spree/api/v3/store/products_controller.rb +12 -19
- data/app/jobs/spree/webhook_delivery_job.rb +4 -1
- 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 +21 -4
- data/app/serializers/spree/api/v3/category_serializer.rb +71 -0
- data/app/serializers/spree/api/v3/country_serializer.rb +2 -1
- 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/order_serializer.rb +2 -2
- 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 +31 -25
- 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 +9 -9
- data/lib/spree/api/configuration.rb +3 -1
- data/lib/spree/api/dependencies.rb +28 -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 +43 -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',
|
|
@@ -50,6 +51,9 @@ module Spree
|
|
|
50
51
|
# Rate limiting errors
|
|
51
52
|
rate_limit_exceeded: 'rate_limit_exceeded',
|
|
52
53
|
|
|
54
|
+
# Idempotency errors
|
|
55
|
+
idempotency_key_reused: 'idempotency_key_reused',
|
|
56
|
+
|
|
53
57
|
# Request errors
|
|
54
58
|
request_too_large: 'request_too_large',
|
|
55
59
|
|
|
@@ -236,9 +240,15 @@ module Spree
|
|
|
236
240
|
end
|
|
237
241
|
|
|
238
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.
|
|
239
245
|
def generate_not_found_message(exception)
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
242
252
|
end
|
|
243
253
|
|
|
244
254
|
# Extract clean model name from exception
|
|
@@ -71,10 +71,18 @@ module Spree
|
|
|
71
71
|
# Build a cache key for a collection
|
|
72
72
|
# Includes: latest updated_at, total count, query params, pagination, expand, currency, locale
|
|
73
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
|
+
|
|
74
81
|
parts = [
|
|
75
|
-
|
|
76
|
-
@pagy&.count,
|
|
82
|
+
latest_updated_at,
|
|
83
|
+
@pagy&.count || collection.size,
|
|
77
84
|
params[:expand],
|
|
85
|
+
params[:fields],
|
|
78
86
|
params[:q]&.to_json,
|
|
79
87
|
params[:page],
|
|
80
88
|
params[:limit],
|
|
@@ -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,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
|
|
@@ -35,6 +39,30 @@ module Spree
|
|
|
35
39
|
|
|
36
40
|
expand_param.to_s.split(',').map(&:strip)
|
|
37
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
|
|
65
|
+
end
|
|
38
66
|
end
|
|
39
67
|
end
|
|
40
68
|
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,8 +107,9 @@ 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
|
|
|
@@ -119,9 +130,30 @@ module Spree
|
|
|
119
130
|
[]
|
|
120
131
|
end
|
|
121
132
|
|
|
122
|
-
# 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
|
|
123
136
|
def ransack_params
|
|
124
|
-
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]
|
|
125
157
|
end
|
|
126
158
|
|
|
127
159
|
# Pagination parameters
|
|
@@ -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
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V3
|
|
4
|
+
module Store
|
|
5
|
+
module Categories
|
|
6
|
+
class ProductsController < Store::ProductsController
|
|
7
|
+
before_action :set_category
|
|
8
|
+
|
|
9
|
+
protected
|
|
10
|
+
|
|
11
|
+
def set_category
|
|
12
|
+
@category = find_category
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def scope
|
|
16
|
+
super.in_category(@category)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def find_category
|
|
22
|
+
id = params[:category_id]
|
|
23
|
+
category_scope = Spree::Category.for_store(current_store).accessible_by(current_ability, :show)
|
|
24
|
+
category_scope = category_scope.i18n if Spree::Category.include?(Spree::TranslatableResource)
|
|
25
|
+
|
|
26
|
+
if id.to_s.start_with?('ctg_')
|
|
27
|
+
category_scope.find_by_prefix_id!(id)
|
|
28
|
+
else
|
|
29
|
+
find_with_fallback_default_locale { category_scope.i18n.find_by!(permalink: id) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -2,22 +2,24 @@ module Spree
|
|
|
2
2
|
module Api
|
|
3
3
|
module V3
|
|
4
4
|
module Store
|
|
5
|
-
class
|
|
5
|
+
class CategoriesController < ResourceController
|
|
6
|
+
include Spree::Api::V3::HttpCaching
|
|
7
|
+
|
|
6
8
|
protected
|
|
7
9
|
|
|
8
10
|
def model_class
|
|
9
|
-
Spree::
|
|
11
|
+
Spree::Category
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
def serializer_class
|
|
13
|
-
Spree.api.
|
|
15
|
+
Spree.api.category_serializer
|
|
14
16
|
end
|
|
15
17
|
|
|
16
|
-
# Find
|
|
17
|
-
# Falls back to default locale if
|
|
18
|
+
# Find category by permalink or prefixed ID with i18n scope for SEO-friendly URLs
|
|
19
|
+
# Falls back to default locale if category is not found in the current locale
|
|
18
20
|
def find_resource
|
|
19
21
|
id = params[:id]
|
|
20
|
-
if id.to_s.start_with?('
|
|
22
|
+
if id.to_s.start_with?('ctg_')
|
|
21
23
|
scope.find_by_prefix_id!(id)
|
|
22
24
|
else
|
|
23
25
|
find_with_fallback_default_locale { scope.i18n.find_by!(permalink: id) }
|
|
@@ -3,10 +3,14 @@ module Spree
|
|
|
3
3
|
module V3
|
|
4
4
|
module Store
|
|
5
5
|
class CountriesController < Store::BaseController
|
|
6
|
+
include Spree::Api::V3::HttpCaching
|
|
7
|
+
|
|
6
8
|
# GET /api/v3/store/countries
|
|
7
9
|
def index
|
|
8
10
|
countries = current_store.countries_from_markets
|
|
9
11
|
|
|
12
|
+
return unless cache_collection(countries)
|
|
13
|
+
|
|
10
14
|
render json: {
|
|
11
15
|
data: countries.map { |country| serialize_country(country) }
|
|
12
16
|
}
|
|
@@ -16,6 +20,8 @@ module Spree
|
|
|
16
20
|
def show
|
|
17
21
|
country = current_store.countries_from_markets.find_by!(iso: params[:id].upcase)
|
|
18
22
|
|
|
23
|
+
return unless cache_resource(country)
|
|
24
|
+
|
|
19
25
|
render json: serialize_country(country)
|
|
20
26
|
end
|
|
21
27
|
|
|
@@ -3,10 +3,14 @@ module Spree
|
|
|
3
3
|
module V3
|
|
4
4
|
module Store
|
|
5
5
|
class CurrenciesController < Store::BaseController
|
|
6
|
+
include Spree::Api::V3::HttpCaching
|
|
7
|
+
|
|
6
8
|
# GET /api/v3/store/currencies
|
|
7
9
|
def index
|
|
8
10
|
currencies = current_store.supported_currencies_list
|
|
9
11
|
|
|
12
|
+
return unless cache_collection(currencies)
|
|
13
|
+
|
|
10
14
|
render json: {
|
|
11
15
|
data: currencies.map { |currency| Spree.api.currency_serializer.new(currency).to_h }
|
|
12
16
|
}
|