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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +4 -4
  3. data/app/controllers/concerns/spree/api/v3/api_key_authentication.rb +1 -1
  4. data/app/controllers/concerns/spree/api/v3/error_handler.rb +14 -2
  5. data/app/controllers/concerns/spree/api/v3/http_caching.rb +15 -7
  6. data/app/controllers/concerns/spree/api/v3/idempotent.rb +82 -0
  7. data/app/controllers/concerns/spree/api/v3/jwt_authentication.rb +4 -1
  8. data/app/controllers/concerns/spree/api/v3/order_concern.rb +1 -6
  9. data/app/controllers/concerns/spree/api/v3/order_lock.rb +42 -0
  10. data/app/controllers/concerns/spree/api/v3/rate_limit_headers.rb +31 -0
  11. data/app/controllers/concerns/spree/api/v3/resource_serializer.rb +37 -9
  12. data/app/controllers/concerns/spree/api/v3/security_headers.rb +4 -0
  13. data/app/controllers/spree/api/v3/admin/base_controller.rb +28 -0
  14. data/app/controllers/spree/api/v3/admin/resource_controller.rb +28 -0
  15. data/app/controllers/spree/api/v3/base_controller.rb +20 -1
  16. data/app/controllers/spree/api/v3/resource_controller.rb +42 -4
  17. data/app/controllers/spree/api/v3/store/auth_controller.rb +4 -24
  18. data/app/controllers/spree/api/v3/store/base_controller.rb +0 -11
  19. data/app/controllers/spree/api/v3/store/cart_controller.rb +6 -2
  20. data/app/controllers/spree/api/v3/store/categories/products_controller.rb +37 -0
  21. data/app/controllers/spree/api/v3/store/{taxons_controller.rb → categories_controller.rb} +8 -6
  22. data/app/controllers/spree/api/v3/store/countries_controller.rb +6 -0
  23. data/app/controllers/spree/api/v3/store/currencies_controller.rb +4 -0
  24. data/app/controllers/spree/api/v3/store/customers_controller.rb +102 -0
  25. data/app/controllers/spree/api/v3/store/locales_controller.rb +4 -0
  26. data/app/controllers/spree/api/v3/store/markets/countries_controller.rb +45 -0
  27. data/app/controllers/spree/api/v3/store/markets_controller.rb +50 -0
  28. data/app/controllers/spree/api/v3/store/orders/coupon_codes_controller.rb +18 -13
  29. data/app/controllers/spree/api/v3/store/orders/line_items_controller.rb +43 -36
  30. data/app/controllers/spree/api/v3/store/orders/shipments_controller.rb +9 -6
  31. data/app/controllers/spree/api/v3/store/orders/store_credits_controller.rb +18 -13
  32. data/app/controllers/spree/api/v3/store/orders_controller.rb +39 -29
  33. data/app/controllers/spree/api/v3/store/products/filters_controller.rb +9 -8
  34. data/app/controllers/spree/api/v3/store/products_controller.rb +29 -17
  35. data/app/jobs/spree/webhook_delivery_job.rb +4 -1
  36. data/app/serializers/spree/api/v3/address_serializer.rb +3 -2
  37. data/app/serializers/spree/api/v3/admin/address_serializer.rb +24 -0
  38. data/app/serializers/spree/api/v3/admin/adjustment_serializer.rb +36 -0
  39. data/app/serializers/spree/api/v3/admin/admin_user_serializer.rb +15 -0
  40. data/app/serializers/spree/api/v3/admin/asset_serializer.rb +10 -0
  41. data/app/serializers/spree/api/v3/admin/category_serializer.rb +33 -0
  42. data/app/serializers/spree/api/v3/admin/credit_card_serializer.rb +22 -0
  43. data/app/serializers/spree/api/v3/admin/customer_serializer.rb +8 -6
  44. data/app/serializers/spree/api/v3/admin/digital_link_serializer.rb +10 -0
  45. data/app/serializers/spree/api/v3/admin/image_serializer.rb +10 -0
  46. data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +36 -1
  47. data/app/serializers/spree/api/v3/admin/option_type_serializer.rb +13 -0
  48. data/app/serializers/spree/api/v3/admin/option_value_serializer.rb +13 -0
  49. data/app/serializers/spree/api/v3/admin/order_promotion_serializer.rb +10 -0
  50. data/app/serializers/spree/api/v3/admin/order_serializer.rb +47 -6
  51. data/app/serializers/spree/api/v3/admin/payment_method_serializer.rb +14 -0
  52. data/app/serializers/spree/api/v3/admin/payment_serializer.rb +56 -0
  53. data/app/serializers/spree/api/v3/admin/payment_source_serializer.rb +10 -0
  54. data/app/serializers/spree/api/v3/admin/product_serializer.rb +23 -6
  55. data/app/serializers/spree/api/v3/admin/refund_serializer.rb +21 -0
  56. data/app/serializers/spree/api/v3/admin/reimbursement_serializer.rb +13 -0
  57. data/app/serializers/spree/api/v3/admin/return_authorization_serializer.rb +17 -0
  58. data/app/serializers/spree/api/v3/admin/shipment_serializer.rb +44 -0
  59. data/app/serializers/spree/api/v3/admin/shipping_category_serializer.rb +14 -0
  60. data/app/serializers/spree/api/v3/admin/shipping_method_serializer.rb +11 -0
  61. data/app/serializers/spree/api/v3/admin/shipping_rate_serializer.rb +11 -0
  62. data/app/serializers/spree/api/v3/admin/stock_item_serializer.rb +17 -0
  63. data/app/serializers/spree/api/v3/admin/stock_location_serializer.rb +15 -0
  64. data/app/serializers/spree/api/v3/admin/store_credit_serializer.rb +27 -0
  65. data/app/serializers/spree/api/v3/admin/tax_category_serializer.rb +15 -0
  66. data/app/serializers/spree/api/v3/admin/variant_serializer.rb +11 -14
  67. data/app/serializers/spree/api/v3/base_serializer.rb +28 -11
  68. data/app/serializers/spree/api/v3/category_serializer.rb +71 -0
  69. data/app/serializers/spree/api/v3/country_serializer.rb +7 -17
  70. data/app/serializers/spree/api/v3/customer_serializer.rb +2 -1
  71. data/app/serializers/spree/api/v3/gift_card_serializer.rb +5 -21
  72. data/app/serializers/spree/api/v3/market_serializer.rb +23 -0
  73. data/app/serializers/spree/api/v3/order_serializer.rb +4 -4
  74. data/app/serializers/spree/api/v3/payment_serializer.rb +1 -1
  75. data/app/serializers/spree/api/v3/product_serializer.rb +11 -11
  76. data/app/serializers/spree/api/v3/shipping_category_serializer.rb +11 -0
  77. data/app/serializers/spree/api/v3/shipping_rate_serializer.rb +2 -6
  78. data/app/serializers/spree/api/v3/tax_category_serializer.rb +11 -0
  79. data/app/serializers/spree/api/v3/variant_serializer.rb +4 -4
  80. data/app/serializers/spree/api/v3/wishlist_serializer.rb +1 -1
  81. data/app/services/spree/api/v3/filters_aggregator.rb +36 -43
  82. data/app/services/spree/webhooks/deliver_webhook.rb +23 -17
  83. data/app/subscribers/spree/webhook_event_subscriber.rb +1 -1
  84. data/config/initializers/alba.rb +1 -1
  85. data/config/initializers/typelizer.rb +26 -16
  86. data/config/locales/en.yml +1 -0
  87. data/config/routes.rb +17 -9
  88. data/lib/spree/api/configuration.rb +3 -1
  89. data/lib/spree/api/dependencies.rb +29 -5
  90. data/lib/spree/api/engine.rb +15 -0
  91. data/lib/spree/api/openapi/schema_helper.rb +27 -7
  92. data/lib/spree/api/testing_support/v3/base.rb +24 -1
  93. metadata +47 -19
  94. data/app/controllers/spree/api/v3/store/customer/account_controller.rb +0 -38
  95. data/app/controllers/spree/api/v3/store/stores_controller.rb +0 -26
  96. data/app/controllers/spree/api/v3/store/taxonomies_controller.rb +0 -19
  97. data/app/controllers/spree/api/v3/store/taxons/products_controller.rb +0 -37
  98. data/app/serializers/spree/api/v3/admin/taxon_serializer.rb +0 -20
  99. data/app/serializers/spree/api/v3/admin/taxonomy_serializer.rb +0 -15
  100. data/app/serializers/spree/api/v3/store_serializer.rb +0 -38
  101. data/app/serializers/spree/api/v3/taxon_serializer.rb +0 -78
  102. data/app/serializers/spree/api/v3/taxonomy_serializer.rb +0 -33
  103. 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: 00acdb80bb37236902858e0cf919b67cdbc8de40f54669d01db8b91ce87691d6
4
- data.tar.gz: 81f453d6ab3a5e4083263d67e6c383a3aa4a3f64e67f2077fda8a4ad6eec4e2f
3
+ metadata.gz: 03df105b93ed4bea7174ff452d0b35024cb242b032bf2e546d4bdabab30197d4
4
+ data.tar.gz: 4259a95154f7fbf1a4921a2b7985659b74a1466395e9b72bc6b8bfc5190105ce
5
5
  SHA512:
6
- metadata.gz: 56e2fbc855fd3dc4acc1655ec2cbfce11c89dbcc356404d4b19e872fa9c8e6d85f36525f7b9789de5f13c20b75b4b1c8415fdd56047005ad36cd418636850573
7
- data.tar.gz: 4879e2c6c20455ed8054afcc40653f2774412caed5f19de054655b70e616d98cedb50dd8a7698980044314e51b9a9ddef6eb4013ffbc3f8352b048cb95e128ca
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 from Alba serializers'
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
- puts "Generated TypeScript types for #{serializers.size} serializers"
49
- puts "Output: #{Typelizer.configuration.writer_config.output_dir}"
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'
@@ -68,7 +68,7 @@ module Spree
68
68
  #
69
69
  # @return [String, nil] the API key token
70
70
  def extract_api_key
71
- request.headers['X-Spree-Api-Key'].presence || params[:api_key]
71
+ request.headers['X-Spree-Api-Key'].presence
72
72
  end
73
73
  end
74
74
  end
@@ -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
- model_name = extract_model_name(exception)
239
- Spree.t(:record_not_found, scope: 'api', model: model_name&.humanize || 'record')
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
- before_action :set_vary_headers
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, includes, currency, locale
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
- collection.reorder(nil).cache_key_with_version,
77
- params[:include],
78
- params[:q],
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[:per_page],
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
- Rails.application.credentials.jwt_secret_key || ENV['JWT_SECRET_KEY'] || Rails.application.secret_key_base
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
- # Check x-spree-order-token header first (lowercase for consistency)
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 { |item| serializer_class.new(item, params: serializer_params).to_h }
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
- includes: include_list
30
+ expand: expand_list
27
31
  }
28
32
  end
29
33
 
30
- # Parse include parameter into list
31
- # Supports: ?include=variants,images or ?includes=variants,images
32
- def include_list
33
- include_param = params[:include].presence || params[:includes].presence
34
- return [] unless include_param
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
- include_param.to_s.split(',').map(&:strip)
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, limit: limit, page: page)
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[:per_page]&.to_i || params[:limit]&.to_i || 25
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: 1.minute, store: Rails.cache, only: :create, with: RATE_LIMIT_RESPONSE
8
- rate_limit to: Spree::Api::Config[:rate_limit_register], within: 1.minute, store: Rails.cache, only: :register, with: RATE_LIMIT_RESPONSE
9
- rate_limit to: Spree::Api::Config[:rate_limit_refresh], within: 1.minute, store: Rails.cache, only: :refresh, with: RATE_LIMIT_RESPONSE
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, :register, :oauth_callback]
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(metadata: {})
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