spree_api 5.4.0.beta → 5.4.0.beta2

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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/spree/api/v3/api_key_authentication.rb +28 -3
  3. data/app/controllers/concerns/spree/api/v3/error_handler.rb +6 -0
  4. data/app/controllers/concerns/spree/api/v3/jwt_authentication.rb +29 -6
  5. data/app/controllers/concerns/spree/api/v3/locale_and_currency.rb +123 -11
  6. data/app/controllers/concerns/spree/api/v3/security_headers.rb +22 -0
  7. data/app/controllers/spree/api/v3/base_controller.rb +5 -2
  8. data/app/controllers/spree/api/v3/store/auth_controller.rb +6 -0
  9. data/app/controllers/spree/api/v3/store/base_controller.rb +11 -0
  10. data/app/controllers/spree/api/v3/store/cart_controller.rb +14 -3
  11. data/app/controllers/spree/api/v3/store/orders/line_items_controller.rb +6 -0
  12. data/app/controllers/spree/api/v3/store/orders_controller.rb +3 -1
  13. data/app/controllers/spree/api/v3/store/products_controller.rb +2 -2
  14. data/app/jobs/spree/api_keys/mark_as_used.rb +15 -0
  15. data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +17 -0
  16. data/app/serializers/spree/api/v3/admin/order_serializer.rb +8 -1
  17. data/app/serializers/spree/api/v3/order_serializer.rb +2 -2
  18. data/app/serializers/spree/api/v3/payment_source_serializer.rb +1 -6
  19. data/app/services/spree/api/v3/orders/update.rb +2 -0
  20. data/config/routes.rb +1 -0
  21. data/lib/spree/api/configuration.rb +12 -0
  22. data/lib/spree/api/dependencies.rb +4 -9
  23. data/lib/spree/api/engine.rb +5 -0
  24. data/lib/spree/api/middleware/request_size_limit.rb +36 -0
  25. data/lib/spree/api/openapi/schema_helper.rb +0 -56
  26. data/lib/spree/api/testing_support/v3/base.rb +4 -1
  27. metadata +12 -10
  28. data/app/serializers/spree/api/v3/post_category_serializer.rb +0 -13
  29. data/app/serializers/spree/api/v3/post_serializer.rb +0 -25
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9fa3dee62eb2625c3d4c9d7bd34bad4ca81cf4d2674f769392a24df5e8a01953
4
- data.tar.gz: cce1ee0d81f70c7c06350faed5aee5ccc8e2c1e510f1e598f29e48598c7d0d0e
3
+ metadata.gz: 5e2afbf0958129824507229d5994a8cf37076727f77f7c8a9cebade0a59c237b
4
+ data.tar.gz: ca64a94e5e43b4bbc4e0bbc02123102c0e1c49f03381f41d9021596e4b2fe013
5
5
  SHA512:
6
- metadata.gz: 8dd621f3a2059a1308cfb49fc4ff3c7b9e9ee3be7378d6ba1242d387e157cb5c6b2504e32b9df40fed8a15eb5af52ffecfc506b970c99a08d1a4a9e71d11385e
7
- data.tar.gz: 947276ed295dc4a13408caa6c6da8b5c90d1661b995bd8396007b4363b45c91b9fedf4594261928440ac98bfb99cc8d907f022061d137b227e666d41ad7f0ffd
6
+ metadata.gz: 56393e0ac09195448b830d59283804af1634734c656961fbe07ec21b1ae7f752cdc2a6c0cabd19f5a48a26a548d6ae616cfc5744b823734f3928369d4e201588
7
+ data.tar.gz: 21477917637463a37f0c3085c497974adc26aa3228b7b5665cc0f3da5051f459c5681b853e739fe44f6e4171d0be71a702baadf141dd450a437ffe1ce15cc6fd
@@ -5,9 +5,16 @@ module Spree
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  included do
8
+ # @!attribute [r] current_api_key
9
+ # The authenticated API key for the current request.
10
+ # @return [Spree::ApiKey, nil]
8
11
  attr_reader :current_api_key
9
12
  end
10
13
 
14
+ # Authenticates a publishable API key (pk_*) for Store API requests.
15
+ # Looks up the key by plaintext token scoped to the current store.
16
+ #
17
+ # @return [Boolean] true if authentication succeeded, false otherwise
11
18
  def authenticate_api_key!
12
19
  @current_api_key = current_store.api_keys.active.publishable.find_by(token: extract_api_key)
13
20
 
@@ -20,12 +27,18 @@ module Spree
20
27
  return false
21
28
  end
22
29
 
23
- Spree::ApiKeyTouchJob.perform_later(@current_api_key.id)
30
+ touch_api_key_if_needed(@current_api_key)
24
31
  true
25
32
  end
26
33
 
34
+ # Authenticates a secret API key (sk_*) for Admin API requests.
35
+ # Computes the HMAC-SHA256 digest of the provided token and looks up
36
+ # by +token_digest+, then verifies it belongs to the current store.
37
+ #
38
+ # @return [Boolean] true if authentication succeeded, false otherwise
27
39
  def authenticate_secret_key!
28
- @current_api_key = current_store.api_keys.active.secret.find_by(token: extract_api_key)
40
+ @current_api_key = Spree::ApiKey.find_by_secret_token(extract_api_key)
41
+ @current_api_key = nil if @current_api_key && @current_api_key.store_id != current_store.id
29
42
 
30
43
  unless @current_api_key
31
44
  render_error(
@@ -36,12 +49,24 @@ module Spree
36
49
  return false
37
50
  end
38
51
 
39
- Spree::ApiKeyTouchJob.perform_later(@current_api_key.id)
52
+ touch_api_key_if_needed(@current_api_key)
40
53
  true
41
54
  end
42
55
 
43
56
  private
44
57
 
58
+ # Marks the API key as used at most once per hour
59
+ # to avoid unnecessary DB writes and job queue pressure on every request.
60
+ # This follows the same approach as GitHub's personal access tokens.
61
+ def touch_api_key_if_needed(api_key)
62
+ return if api_key.last_used_at.present? && api_key.last_used_at > 1.hour.ago
63
+
64
+ Spree::ApiKeys::MarkAsUsed.perform_later(api_key.id, Time.current)
65
+ end
66
+
67
+ # Extracts the API key from the request headers or params.
68
+ #
69
+ # @return [String, nil] the API key token
45
70
  def extract_api_key
46
71
  request.headers['X-Spree-Api-Key'].presence || params[:api_key]
47
72
  end
@@ -46,6 +46,12 @@ module Spree
46
46
  digital_link_expired: 'digital_link_expired',
47
47
  download_limit_exceeded: 'download_limit_exceeded',
48
48
 
49
+ # Rate limiting errors
50
+ rate_limit_exceeded: 'rate_limit_exceeded',
51
+
52
+ # Request errors
53
+ request_too_large: 'request_too_large',
54
+
49
55
  # General errors
50
56
  processing_error: 'processing_error',
51
57
  invalid_request: 'invalid_request'
@@ -9,6 +9,10 @@ module Spree
9
9
  USER_TYPE_CUSTOMER = 'customer'.freeze
10
10
  USER_TYPE_ADMIN = 'admin'.freeze
11
11
 
12
+ JWT_AUDIENCE_STORE = 'store_api'.freeze
13
+ JWT_AUDIENCE_ADMIN = 'admin_api'.freeze
14
+ JWT_ISSUER = 'spree'.freeze
15
+
12
16
  included do
13
17
  attr_reader :current_user
14
18
  end
@@ -20,7 +24,8 @@ module Spree
20
24
 
21
25
  payload = decode_jwt(token)
22
26
  @current_user = find_user_from_payload(payload)
23
- rescue JWT::DecodeError, JWT::ExpiredSignature, ActiveRecord::RecordNotFound => e
27
+ rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::InvalidIssuerError,
28
+ JWT::InvalidAudError, ActiveRecord::RecordNotFound => e
24
29
  Rails.logger.debug { "JWT authentication failed: #{e.message}" }
25
30
  @current_user = nil
26
31
  end
@@ -40,12 +45,16 @@ module Spree
40
45
 
41
46
  # Generate a JWT token for a user
42
47
  # @param user [Object] The user to generate a token for
43
- # @param expiration [Integer] Time in seconds until expiration (default 24 hours)
48
+ # @param expiration [Integer] Time in seconds until expiration (default from config, 1 hour)
49
+ # @param audience [String] The audience claim (default: store_api)
44
50
  # @return [String] The JWT token
45
- def generate_jwt(user, expiration: 24.hours.to_i)
51
+ def generate_jwt(user, expiration: jwt_expiration, audience: JWT_AUDIENCE_STORE)
46
52
  payload = {
47
53
  user_id: user.id,
48
54
  user_type: determine_user_type(user),
55
+ jti: SecureRandom.uuid,
56
+ iss: JWT_ISSUER,
57
+ aud: audience,
49
58
  exp: Time.current.to_i + expiration
50
59
  }
51
60
  JWT.encode(payload, jwt_secret, 'HS256')
@@ -58,18 +67,32 @@ module Spree
58
67
  header = request.headers['Authorization']
59
68
  return header.split(' ').last if header.present? && header.start_with?('Bearer ')
60
69
 
61
- # Fallback to query param for special cases (e.g., digital downloads)
62
- params[:token]
70
+ # Restricted fallback: only for digital download endpoints
71
+ params[:token] if controller_name == 'digitals'
63
72
  end
64
73
 
65
74
  def decode_jwt(token)
66
- JWT.decode(token, jwt_secret, true, algorithm: 'HS256').first
75
+ JWT.decode(token, jwt_secret, true,
76
+ algorithm: 'HS256',
77
+ iss: JWT_ISSUER,
78
+ aud: expected_audience,
79
+ verify_iss: true,
80
+ verify_aud: true
81
+ ).first
67
82
  end
68
83
 
69
84
  def jwt_secret
70
85
  Rails.application.credentials.jwt_secret_key || ENV['JWT_SECRET_KEY'] || Rails.application.secret_key_base
71
86
  end
72
87
 
88
+ def jwt_expiration
89
+ Spree::Api::Config[:jwt_expiration]
90
+ end
91
+
92
+ def expected_audience
93
+ JWT_AUDIENCE_STORE
94
+ end
95
+
73
96
  def find_user_from_payload(payload)
74
97
  user_id = payload['user_id']
75
98
  user_type = payload['user_type'] || USER_TYPE_CUSTOMER
@@ -1,60 +1,172 @@
1
1
  module Spree
2
2
  module Api
3
3
  module V3
4
+ # Handles locale, currency, and market resolution for API v3 controllers.
5
+ #
6
+ # This concern is fully self-contained and does not depend on
7
+ # +Spree::Core::ControllerHelpers::Locale+ or +Spree::Core::ControllerHelpers::Currency+.
8
+ #
9
+ # Resolution order:
10
+ # 1. Market is resolved from +x-spree-country+ header (sets +Spree::Current.market+)
11
+ # 2. Locale is resolved: +x-spree-locale+ header > +params[:locale]+ > +Spree::Current.locale+ (market -> store fallback)
12
+ # 3. Currency is resolved: +x-spree-currency+ header > +params[:currency]+ > +Spree::Current.currency+ (market -> store fallback)
13
+ # 4. Mobility fallback locale is configured for the current store
4
14
  module LocaleAndCurrency
5
15
  extend ActiveSupport::Concern
6
16
 
7
17
  included do
8
18
  before_action :set_market_from_country
9
- before_action :set_locale_from_header
10
- before_action :set_currency_from_header
19
+ before_action :set_locale
20
+ before_action :set_currency
21
+ before_action :set_fallback_locale
11
22
  end
12
23
 
13
24
  protected
14
25
 
15
- # Override current_locale to check header first
26
+ # Returns the current locale for this request.
27
+ #
28
+ # Priority: x-spree-locale header > params[:locale] > Spree::Current.locale (market -> store fallback)
29
+ #
30
+ # @return [String] the locale code, e.g. +"en"+, +"fr"+
16
31
  def current_locale
17
32
  @current_locale ||= begin
18
- locale = locale_from_header || locale_from_params || default_locale
19
- locale.to_s if supported_locale?(locale)
20
- end || default_locale
33
+ locale = locale_from_header || locale_from_params
34
+ locale.to_s if locale.present? && supported_locale?(locale)
35
+ end || Spree::Current.locale
21
36
  end
22
37
 
23
- # Override current_currency to check header first
38
+ # Returns the current currency for this request.
39
+ #
40
+ # Priority: x-spree-currency header > params[:currency] > Spree::Current.currency (market -> store fallback)
41
+ #
42
+ # @return [String] the currency ISO code, e.g. +"USD"+, +"EUR"+
24
43
  def current_currency
25
44
  @current_currency ||= begin
26
- currency = currency_from_header || currency_from_params || current_store&.default_currency
45
+ currency = currency_from_header || currency_from_params
27
46
  currency = currency&.upcase
28
- supported_currency?(currency) ? currency : current_store&.default_currency
47
+ currency if currency.present? && supported_currency?(currency)
48
+ end || Spree::Current.currency
49
+ end
50
+
51
+ # Returns the default locale, delegating to +Spree::Current.locale+
52
+ # which falls back through market -> store.
53
+ #
54
+ # @return [String] the default locale code
55
+ def default_locale
56
+ Spree::Current.locale
57
+ end
58
+
59
+ # Returns the list of supported locale codes for the current store.
60
+ #
61
+ # When markets are configured, this aggregates locales from all markets.
62
+ #
63
+ # @return [Array<String>] supported locale codes
64
+ def supported_locales
65
+ @supported_locales ||= current_store&.supported_locales_list
66
+ end
67
+
68
+ # Checks if the given locale is supported by the current store.
69
+ #
70
+ # @param locale_code [String, nil] the locale code to check
71
+ # @return [Boolean]
72
+ def supported_locale?(locale_code)
73
+ return false if supported_locales.nil?
74
+
75
+ supported_locales.include?(locale_code&.to_s)
76
+ end
77
+
78
+ # Returns the list of supported currencies for the current store.
79
+ #
80
+ # When markets are configured, this aggregates currencies from all markets.
81
+ #
82
+ # @return [Array<Money::Currency>] supported currencies
83
+ def supported_currencies
84
+ @supported_currencies ||= current_store&.supported_currencies_list
85
+ end
86
+
87
+ # Checks if the given currency ISO code is supported by the current store.
88
+ #
89
+ # @param currency_iso_code [String, nil] the currency ISO code to check, e.g. +"USD"+
90
+ # @return [Boolean]
91
+ def supported_currency?(currency_iso_code)
92
+ return false if supported_currencies.nil?
93
+
94
+ supported_currencies.map(&:iso_code).include?(currency_iso_code&.upcase)
95
+ end
96
+
97
+ # Finds a record using the given block, falling back to the store's default locale
98
+ # if the record is not found in the current locale.
99
+ #
100
+ # Used for slug/permalink lookups where translated slugs may not exist in all locales.
101
+ #
102
+ # @yield the block that performs the lookup
103
+ # @return [ActiveRecord::Base] the found record
104
+ # @raise [ActiveRecord::RecordNotFound] if not found in any locale
105
+ def find_with_fallback_default_locale(&block)
106
+ result = begin
107
+ block.call
108
+ rescue ActiveRecord::RecordNotFound => _e
109
+ nil
29
110
  end
111
+
112
+ result || Mobility.with_locale(current_store.default_locale) { block.call }
30
113
  end
31
114
 
32
115
  private
33
116
 
34
- def set_locale_from_header
117
+ # Sets +I18n.locale+ and +Spree::Current.locale+ from the resolved locale.
118
+ def set_locale
119
+ Spree::Current.locale = current_locale
35
120
  I18n.locale = current_locale
36
121
  end
37
122
 
38
- def set_currency_from_header
123
+ # Sets +Spree::Current.currency+ from the resolved currency.
124
+ def set_currency
39
125
  Spree::Current.currency = current_currency
40
126
  end
41
127
 
128
+ # Configures Mobility fallback locales for the current store.
129
+ #
130
+ # This runs after market resolution so fallbacks are aware of the store's
131
+ # full locale configuration.
132
+ def set_fallback_locale
133
+ return unless current_store.present?
134
+
135
+ Spree::Locales::SetFallbackLocaleForStore.new.call(store: current_store)
136
+ end
137
+
138
+ # Reads the locale from the +x-spree-locale+ request header.
139
+ #
140
+ # @return [String, nil]
42
141
  def locale_from_header
43
142
  request.headers['x-spree-locale'].presence
44
143
  end
45
144
 
145
+ # Reads the currency from the +x-spree-currency+ request header.
146
+ #
147
+ # @return [String, nil]
46
148
  def currency_from_header
47
149
  request.headers['x-spree-currency'].presence
48
150
  end
49
151
 
152
+ # Reads the locale from request params.
153
+ #
154
+ # @return [String, nil]
50
155
  def locale_from_params
51
156
  params[:locale].presence
52
157
  end
53
158
 
159
+ # Reads the currency from request params.
160
+ #
161
+ # @return [String, nil]
54
162
  def currency_from_params
55
163
  params[:currency].presence
56
164
  end
57
165
 
166
+ # Resolves the market from the +x-spree-country+ header or +params[:country]+.
167
+ #
168
+ # When a matching market is found, it is set on +Spree::Current.market+,
169
+ # which influences the default locale and currency fallbacks.
58
170
  def set_market_from_country
59
171
  country_iso = request.headers['x-spree-country'].presence || params[:country].presence
60
172
  return unless country_iso
@@ -0,0 +1,22 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module SecurityHeaders
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ after_action :set_security_headers
9
+ end
10
+
11
+ private
12
+
13
+ def set_security_headers
14
+ response.headers['X-Content-Type-Options'] = 'nosniff'
15
+ response.headers['X-Frame-Options'] = 'DENY'
16
+ response.headers.delete('X-Powered-By')
17
+ response.headers.delete('Server')
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -6,13 +6,12 @@ module Spree
6
6
  include CanCan::ControllerAdditions
7
7
  include Spree::Core::ControllerHelpers::StrongParameters
8
8
  include Spree::Core::ControllerHelpers::Store
9
- include Spree::Core::ControllerHelpers::Locale
10
- include Spree::Core::ControllerHelpers::Currency
11
9
  include Spree::Api::V3::LocaleAndCurrency
12
10
  include Spree::Api::V3::JwtAuthentication
13
11
  include Spree::Api::V3::ApiKeyAuthentication
14
12
  include Spree::Api::V3::ErrorHandler
15
13
  include Spree::Api::V3::HttpCaching
14
+ include Spree::Api::V3::SecurityHeaders
16
15
  include Spree::Api::V3::ResourceSerializer
17
16
  include Pagy::Method
18
17
 
@@ -22,6 +21,7 @@ module Spree
22
21
  protected
23
22
 
24
23
  # Override to use current_user from JWT authentication
24
+ # @return [Spree.user_class]
25
25
  def spree_current_user
26
26
  current_user
27
27
  end
@@ -29,10 +29,13 @@ module Spree
29
29
  alias try_spree_current_user spree_current_user
30
30
 
31
31
  # CanCanCan ability
32
+ # @return [Spree::Ability]
32
33
  def current_ability
33
34
  @current_ability ||= Spree::Ability.new(current_user, ability_options)
34
35
  end
35
36
 
37
+ # Options passed to the CanCanCan ability
38
+ # @return [Hash]
36
39
  def ability_options
37
40
  { store: current_store }
38
41
  end
@@ -3,6 +3,12 @@ module Spree
3
3
  module V3
4
4
  module Store
5
5
  class AuthController < Store::BaseController
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
11
+
6
12
  skip_before_action :authenticate_user, only: [:create, :register, :oauth_callback]
7
13
  prepend_before_action :require_authentication!, only: [:refresh]
8
14
 
@@ -3,6 +3,17 @@ 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
+
6
17
  # Require publishable API key for all Store API requests
7
18
  before_action :authenticate_api_key!
8
19
  end
@@ -11,13 +11,20 @@ module Spree
11
11
  # Creates a new shopping cart (order)
12
12
  # Can be created by guests or authenticated customers
13
13
  def create
14
- @cart = Spree::Order.create!(
14
+ result = Spree.cart_create_service.call(
15
+ user: current_user,
15
16
  store: current_store,
16
17
  currency: current_currency,
17
- user: current_user # nil for guests
18
+ locale: current_locale,
19
+ metadata: cart_params[:metadata] || {}
18
20
  )
19
21
 
20
- render json: serialize_resource(@cart), status: :created
22
+ if result.success?
23
+ @cart = result.value
24
+ render json: serialize_resource(@cart), status: :created
25
+ else
26
+ render_service_error(result.error.to_s)
27
+ end
21
28
  end
22
29
 
23
30
  # GET /api/v3/store/cart
@@ -53,6 +60,10 @@ module Spree
53
60
 
54
61
  private
55
62
 
63
+ def cart_params
64
+ params.permit(metadata: {})
65
+ end
66
+
56
67
  # Find incomplete cart by order token for associate action
57
68
  # Only finds guest carts (no user) or carts already owned by current user (idempotent)
58
69
  def find_cart_by_token
@@ -15,6 +15,7 @@ module Spree
15
15
  order: @parent,
16
16
  variant: variant,
17
17
  quantity: permitted_params[:quantity] || 1,
18
+ metadata: permitted_params[:metadata] || {},
18
19
  options: permitted_params[:options] || {}
19
20
  )
20
21
 
@@ -29,6 +30,8 @@ module Spree
29
30
  def update
30
31
  @line_item = scope.find_by_prefix_id!(params[:id])
31
32
 
33
+ @line_item.metadata = @line_item.metadata.merge(permitted_params[:metadata].to_h) if permitted_params[:metadata].present?
34
+
32
35
  if permitted_params[:quantity].present?
33
36
  result = Spree.cart_set_item_quantity_service.call(
34
37
  order: @parent,
@@ -41,6 +44,9 @@ module Spree
41
44
  else
42
45
  render_service_error(result.error, code: ERROR_CODES[:invalid_quantity])
43
46
  end
47
+ elsif @line_item.changed?
48
+ @line_item.save!
49
+ render_order
44
50
  else
45
51
  render_order
46
52
  end
@@ -103,11 +103,13 @@ module Spree
103
103
  params.permit(
104
104
  :email,
105
105
  :currency,
106
+ :locale,
106
107
  :special_instructions,
107
108
  :ship_address_id,
108
109
  :bill_address_id,
109
110
  ship_address: address_params,
110
- bill_address: address_params
111
+ bill_address: address_params,
112
+ metadata: {}
111
113
  )
112
114
  end
113
115
 
@@ -38,8 +38,8 @@ module Spree
38
38
  def scope_includes
39
39
  [
40
40
  thumbnail: [attachment_attachment: :blob],
41
- master: [:prices],
42
- variants: [:prices]
41
+ master: [:prices, stock_items: :stock_location],
42
+ variants: [:prices, stock_items: :stock_location]
43
43
  ]
44
44
  end
45
45
 
@@ -0,0 +1,15 @@
1
+ module Spree
2
+ module ApiKeys
3
+ class MarkAsUsed < Spree::BaseJob
4
+ queue_as Spree.queues.api_keys
5
+
6
+ def perform(api_key_id, used_at)
7
+ api_key = Spree::ApiKey.find_by(id: api_key_id)
8
+ return if api_key.nil?
9
+ return if api_key.last_used_at.present? && api_key.last_used_at >= used_at
10
+
11
+ api_key.update_column(:last_used_at, used_at)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,17 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # Admin API Line Item Serializer
6
+ # Extends the store serializer with metadata visibility
7
+ class LineItemSerializer < V3::LineItemSerializer
8
+ typelize metadata: 'Record<string, unknown> | null'
9
+
10
+ attribute :metadata do |line_item|
11
+ line_item.metadata.presence
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -11,7 +11,8 @@ module Spree
11
11
  store_owner_notification_delivered: :boolean,
12
12
  internal_note: [:string, nullable: true], approver_id: [:string, nullable: true],
13
13
  canceler_id: [:string, nullable: true], created_by_id: [:string, nullable: true],
14
- canceled_at: [:string, nullable: true], approved_at: [:string, nullable: true]
14
+ canceled_at: [:string, nullable: true], approved_at: [:string, nullable: true],
15
+ metadata: 'Record<string, unknown> | null'
15
16
 
16
17
  # Admin-only attributes
17
18
  attributes :channel, :last_ip_address, :considered_risky,
@@ -19,6 +20,10 @@ module Spree
19
20
  :internal_note, :approver_id,
20
21
  canceled_at: :iso8601, approved_at: :iso8601
21
22
 
23
+ attribute :metadata do |order|
24
+ order.metadata.presence
25
+ end
26
+
22
27
  attribute :canceler_id do |order|
23
28
  order.canceler_id
24
29
  end
@@ -27,6 +32,8 @@ module Spree
27
32
  order.created_by_id
28
33
  end
29
34
 
35
+ many :line_items, resource: Spree.api.admin_line_item_serializer
36
+
30
37
  one :user,
31
38
  resource: Spree.api.admin_customer_serializer,
32
39
  if: proc { params[:includes]&.include?('user') }
@@ -5,7 +5,7 @@ module Spree
5
5
  # Customer-facing order data
6
6
  class OrderSerializer < BaseSerializer
7
7
  typelize number: :string, state: :string, token: :string, email: [:string, nullable: true],
8
- special_instructions: [:string, nullable: true], currency: :string, item_count: :number,
8
+ special_instructions: [:string, nullable: true], currency: :string, locale: [:string, nullable: true], item_count: :number,
9
9
  shipment_state: [:string, nullable: true], payment_state: [:string, nullable: true],
10
10
  item_total: :string, display_item_total: :string,
11
11
  ship_total: :string, display_ship_total: :string,
@@ -18,7 +18,7 @@ module Spree
18
18
  bill_address: { nullable: true }, ship_address: { nullable: true }
19
19
 
20
20
  attributes :number, :state, :token, :email, :special_instructions,
21
- :currency, :item_count, :shipment_state, :payment_state,
21
+ :currency, :locale, :item_count, :shipment_state, :payment_state,
22
22
  :item_total, :display_item_total, :ship_total, :display_ship_total,
23
23
  :adjustment_total, :display_adjustment_total, :promo_total, :display_promo_total,
24
24
  :tax_total, :display_tax_total, :included_tax_total, :display_included_tax_total,
@@ -2,16 +2,11 @@ module Spree
2
2
  module Api
3
3
  module V3
4
4
  class PaymentSourceSerializer < BaseSerializer
5
- typelize gateway_payment_profile_id: [:string, nullable: true],
6
- public_metadata: ['Record<string, unknown>', nullable: true]
5
+ typelize gateway_payment_profile_id: [:string, nullable: true]
7
6
 
8
7
  attribute :gateway_payment_profile_id do |source|
9
8
  source.try(:gateway_payment_profile_id)
10
9
  end
11
-
12
- attribute :public_metadata do |source|
13
- source.try(:public_metadata)
14
- end
15
10
  end
16
11
  end
17
12
  end
@@ -54,6 +54,8 @@ module Spree
54
54
  order.email = params[:email] if params[:email].present?
55
55
  order.special_instructions = params[:special_instructions] if params.key?(:special_instructions)
56
56
  order.currency = params[:currency].upcase if params[:currency].present?
57
+ order.locale = params[:locale] if params[:locale].present?
58
+ order.metadata = order.metadata.merge(params[:metadata].to_h) if params[:metadata].present?
57
59
  end
58
60
 
59
61
  def assign_address(address_type)
data/config/routes.rb CHANGED
@@ -82,6 +82,7 @@ Spree::Core::Engine.add_routes do
82
82
  # Digital Downloads
83
83
  # Access via token in URL
84
84
  get 'digitals/:token', to: 'digitals#show', as: :digital_download
85
+
85
86
  end
86
87
  end
87
88
  end
@@ -9,6 +9,18 @@ module Spree
9
9
  preference :api_v2_content_type, :string, default: 'application/vnd.api+json'
10
10
  preference :api_v2_per_page_limit, :integer, default: 500
11
11
 
12
+ preference :jwt_expiration, :integer, default: 3600 # 1 hour in seconds
13
+
14
+ # Rate limiting (requests per minute)
15
+ preference :rate_limit_per_key, :integer, default: 300 # per publishable API key
16
+ preference :rate_limit_login, :integer, default: 5 # per IP
17
+ preference :rate_limit_register, :integer, default: 3 # per IP
18
+ preference :rate_limit_refresh, :integer, default: 10 # per IP
19
+ preference :rate_limit_oauth, :integer, default: 5 # per IP
20
+
21
+ # Request body size limit in bytes
22
+ preference :max_request_body_size, :integer, default: 102_400 # 100KB
23
+
12
24
  preference :webhooks_enabled, :boolean, default: true
13
25
  preference :webhooks_verify_ssl, :boolean, default: !Rails.env.development?
14
26
  end
@@ -72,8 +72,6 @@ module Spree
72
72
  storefront_line_item_serializer: 'Spree::V2::Storefront::LineItemSerializer',
73
73
  storefront_option_type_serializer: 'Spree::V2::Storefront::OptionTypeSerializer',
74
74
  storefront_option_value_serializer: 'Spree::V2::Storefront::OptionValueSerializer',
75
- storefront_post_category_serializer: 'Spree::V2::Storefront::PostCategorySerializer',
76
- storefront_post_serializer: 'Spree::V2::Storefront::PostSerializer',
77
75
  storefront_product_property_serializer: 'Spree::V2::Storefront::ProductPropertySerializer',
78
76
  storefront_order_promotion_serializer: 'Spree::V2::Storefront::OrderPromotionSerializer',
79
77
  storefront_shipping_method_serializer: 'Spree::V2::Storefront::ShippingMethodSerializer',
@@ -136,8 +134,6 @@ module Spree
136
134
  import_row_serializer: 'Spree::Api::V3::ImportRowSerializer',
137
135
  invitation_serializer: 'Spree::Api::V3::InvitationSerializer',
138
136
  newsletter_subscriber_serializer: 'Spree::Api::V3::NewsletterSubscriberSerializer',
139
- post_serializer: 'Spree::Api::V3::PostSerializer',
140
- post_category_serializer: 'Spree::Api::V3::PostCategorySerializer',
141
137
  promotion_serializer: 'Spree::Api::V3::PromotionSerializer',
142
138
  refund_serializer: 'Spree::Api::V3::RefundSerializer',
143
139
  reimbursement_serializer: 'Spree::Api::V3::ReimbursementSerializer',
@@ -156,6 +152,7 @@ module Spree
156
152
  admin_price_serializer: 'Spree::Api::V3::Admin::PriceSerializer',
157
153
  admin_metafield_serializer: 'Spree::Api::V3::Admin::MetafieldSerializer',
158
154
  admin_taxon_serializer: 'Spree::Api::V3::Admin::TaxonSerializer',
155
+ admin_line_item_serializer: 'Spree::Api::V3::Admin::LineItemSerializer',
159
156
  admin_taxonomy_serializer: 'Spree::Api::V3::Admin::TaxonomySerializer',
160
157
 
161
158
  # platform serializers
@@ -164,10 +161,9 @@ module Spree
164
161
  # sorters
165
162
  storefront_collection_sorter: -> { Spree::Dependencies.collection_sorter },
166
163
  storefront_order_sorter: -> { Spree::Dependencies.collection_sorter },
164
+ storefront_posts_sorter: nil,
167
165
  storefront_products_sorter: -> { Spree::Dependencies.products_sorter },
168
166
  platform_products_sorter: -> { Spree::Dependencies.products_sorter },
169
- storefront_posts_sorter: -> { Spree::Dependencies.posts_sorter },
170
-
171
167
  # paginators
172
168
  storefront_collection_paginator: 'Spree::Api::Paginate',
173
169
 
@@ -180,8 +176,8 @@ module Spree
180
176
  storefront_completed_order_finder: -> { Spree::Dependencies.completed_order_finder },
181
177
  storefront_credit_card_finder: -> { Spree::Dependencies.credit_card_finder },
182
178
  storefront_find_by_variant_finder: -> { Spree::Dependencies.line_item_by_variant_finder },
179
+ storefront_posts_finder: nil,
183
180
  storefront_products_finder: -> { Spree::Dependencies.products_finder },
184
- storefront_posts_finder: -> { Spree::Dependencies.posts_finder },
185
181
  storefront_taxon_finder: -> { Spree::Dependencies.taxon_finder },
186
182
  storefront_variant_finder: -> { Spree::Dependencies.variant_finder },
187
183
 
@@ -195,7 +191,6 @@ module Spree
195
191
  platform_country_serializer: 'Spree::Api::V2::Platform::CountrySerializer',
196
192
  platform_credit_card_serializer: 'Spree::Api::V2::Platform::CreditCardSerializer',
197
193
  platform_customer_return_serializer: 'Spree::Api::V2::Platform::CustomerReturnSerializer',
198
- platform_data_feed_serializer: 'Spree::Api::V2::Platform::DataFeedSerializer',
199
194
  platform_digital_link_serializer: 'Spree::Api::V2::Platform::DigitalLinkSerializer',
200
195
  platform_digital_serializer: 'Spree::Api::V2::Platform::DigitalSerializer',
201
196
  platform_gift_card_serializer: 'Spree::Api::V2::Platform::GiftCardSerializer',
@@ -218,8 +213,8 @@ module Spree
218
213
  platform_promotion_action_serializer: 'Spree::Api::V2::Platform::PromotionActionSerializer',
219
214
  platform_promotion_category_serializer: 'Spree::Api::V2::Platform::PromotionCategorySerializer',
220
215
  platform_promotion_rule_serializer: 'Spree::Api::V2::Platform::PromotionRuleSerializer',
221
- platform_promotion_serializer: 'Spree::Api::V2::Platform::PromotionSerializer',
222
216
  platform_property_serializer: 'Spree::Api::V2::Platform::PropertySerializer',
217
+ platform_promotion_serializer: 'Spree::Api::V2::Platform::PromotionSerializer',
223
218
  platform_prototype_serializer: 'Spree::Api::V2::Platform::PrototypeSerializer',
224
219
  platform_refund_reason_serializer: 'Spree::Api::V2::Platform::RefundReasonSerializer',
225
220
  platform_refund_serializer: 'Spree::Api::V2::Platform::RefundSerializer',
@@ -14,6 +14,11 @@ module Spree
14
14
  Spree::Api::Dependencies = Spree::Api::ApiDependencies.new
15
15
  end
16
16
 
17
+ initializer 'spree.api.request_size_limit' do |app|
18
+ require_relative 'middleware/request_size_limit'
19
+ app.middleware.insert_before Rack::Runtime, Spree::Api::Middleware::RequestSizeLimit
20
+ end
21
+
17
22
  # Add API event subscribers
18
23
  config.after_initialize do
19
24
  Spree.subscribers << Spree::WebhookEventSubscriber
@@ -0,0 +1,36 @@
1
+ module Spree
2
+ module Api
3
+ module Middleware
4
+ class RequestSizeLimit
5
+ def initialize(app, limit: nil)
6
+ @app = app
7
+ @limit = limit
8
+ end
9
+
10
+ def call(env)
11
+ if api_request?(env) && content_length_exceeded?(env)
12
+ body = { error: { code: 'request_too_large', message: 'Request body too large' } }
13
+ [413, { 'Content-Type' => 'application/json' }, [body.to_json]]
14
+ else
15
+ @app.call(env)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def api_request?(env)
22
+ env['PATH_INFO']&.start_with?('/api/v3/')
23
+ end
24
+
25
+ def content_length_exceeded?(env)
26
+ content_length = env['CONTENT_LENGTH'].to_i
27
+ content_length > max_body_size
28
+ end
29
+
30
+ def max_body_size
31
+ @limit || Spree::Api::Config[:max_request_body_size]
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -102,66 +102,10 @@ module Spree
102
102
  with_typelizer_enabled do
103
103
  schemas = Typelizer.openapi_schemas
104
104
  schemas.each_value { |s| s[:'x-typelizer'] = true }
105
- fix_refs(schemas)
106
105
  schemas
107
106
  end
108
107
  end
109
108
 
110
- # Typelizer's native OpenAPI generator treats unrecognized type strings
111
- # as $ref to other schemas. This fixes three cases:
112
- # 1. any → {type: :object}
113
- # 2. Record<string, unknown> → {type: :object}
114
- # 3. Union types (e.g., 'TypeA | TypeB | null') → proper anyOf schemas
115
- def fix_refs(schemas)
116
- schemas.each_value do |schema|
117
- next unless schema[:properties]
118
-
119
- schema[:properties].each do |key, prop|
120
- schema[:properties][key] = fix_ref(prop)
121
- end
122
- end
123
- end
124
-
125
- def fix_ref(prop)
126
- # Direct $ref
127
- if (ref_value = prop['$ref'])
128
- return resolve_ref(ref_value, prop)
129
- end
130
-
131
- # allOf wrapper (used for nullable refs)
132
- if prop[:allOf]&.length == 1 && (ref_value = prop[:allOf][0]['$ref'])
133
- resolved = resolve_ref(ref_value, {})
134
- resolved[:nullable] = true if prop[:nullable]
135
- return resolved
136
- end
137
-
138
- prop
139
- end
140
-
141
- def resolve_ref(ref_value, base)
142
- ref_name = ref_value.sub('#/components/schemas/', '')
143
-
144
- # any → {type: :object} (closest OpenAPI equivalent)
145
- return base.except('$ref').merge(type: :object) if ref_name == 'any'
146
-
147
- # Record<string, unknown> → {type: :object}
148
- if ref_name.start_with?('Record<')
149
- return base.except('$ref').merge(type: :object)
150
- end
151
-
152
- # Union types (e.g., 'TypeA | TypeB | null') → anyOf
153
- if ref_name.include?(' | ')
154
- types = ref_name.split(' | ').map(&:strip)
155
- nullable = types.delete('null')
156
- refs = types.map { |t| { '$ref' => "#/components/schemas/#{t}" } }
157
- result = { anyOf: refs }
158
- result[:nullable] = true if nullable
159
- return result
160
- end
161
-
162
- base
163
- end
164
-
165
109
  # Typelizer is normally disabled in test/production, but we need it
166
110
  # enabled to generate OpenAPI schemas from serializer type hints
167
111
  def with_typelizer_enabled
@@ -3,11 +3,14 @@ module Spree
3
3
  module Api
4
4
  module V3
5
5
  module TestingSupport
6
- def self.generate_jwt(user, expiration: 24.hours.to_i)
6
+ def self.generate_jwt(user, expiration: 1.hour.to_i, audience: Spree::Api::V3::JwtAuthentication::JWT_AUDIENCE_STORE)
7
7
  user_type = user.is_a?(Spree.admin_user_class) ? 'admin' : 'customer'
8
8
  payload = {
9
9
  user_id: user.id,
10
10
  user_type: user_type,
11
+ jti: SecureRandom.uuid,
12
+ iss: Spree::Api::V3::JwtAuthentication::JWT_ISSUER,
13
+ aud: audience,
11
14
  exp: Time.current.to_i + expiration
12
15
  }
13
16
  secret = Rails.application.credentials.jwt_secret_key || ENV['JWT_SECRET_KEY'] || Rails.application.secret_key_base
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.4.0.beta
4
+ version: 5.4.0.beta2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vendo Connect Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-25 00:00:00.000000000 Z
11
+ date: 2026-02-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rswag-specs
@@ -72,28 +72,28 @@ dependencies:
72
72
  requirements:
73
73
  - - "~>"
74
74
  - !ruby/object:Gem::Version
75
- version: '0.8'
75
+ version: '0.9'
76
76
  type: :runtime
77
77
  prerelease: false
78
78
  version_requirements: !ruby/object:Gem::Requirement
79
79
  requirements:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
- version: '0.8'
82
+ version: '0.9'
83
83
  - !ruby/object:Gem::Dependency
84
84
  name: spree_core
85
85
  requirement: !ruby/object:Gem::Requirement
86
86
  requirements:
87
87
  - - '='
88
88
  - !ruby/object:Gem::Version
89
- version: 5.4.0.beta
89
+ version: 5.4.0.beta2
90
90
  type: :runtime
91
91
  prerelease: false
92
92
  version_requirements: !ruby/object:Gem::Requirement
93
93
  requirements:
94
94
  - - '='
95
95
  - !ruby/object:Gem::Version
96
- version: 5.4.0.beta
96
+ version: 5.4.0.beta2
97
97
  description: Spree's API
98
98
  email:
99
99
  - hello@spreecommerce.org
@@ -110,6 +110,7 @@ files:
110
110
  - app/controllers/concerns/spree/api/v3/locale_and_currency.rb
111
111
  - app/controllers/concerns/spree/api/v3/order_concern.rb
112
112
  - app/controllers/concerns/spree/api/v3/resource_serializer.rb
113
+ - app/controllers/concerns/spree/api/v3/security_headers.rb
113
114
  - app/controllers/spree/api/v3/base_controller.rb
114
115
  - app/controllers/spree/api/v3/resource_controller.rb
115
116
  - app/controllers/spree/api/v3/store/auth_controller.rb
@@ -142,9 +143,11 @@ files:
142
143
  - app/controllers/spree/api/v3/store/taxons_controller.rb
143
144
  - app/controllers/spree/api/v3/store/wishlist_items_controller.rb
144
145
  - app/controllers/spree/api/v3/store/wishlists_controller.rb
146
+ - app/jobs/spree/api_keys/mark_as_used.rb
145
147
  - app/jobs/spree/webhook_delivery_job.rb
146
148
  - app/serializers/spree/api/v3/address_serializer.rb
147
149
  - app/serializers/spree/api/v3/admin/customer_serializer.rb
150
+ - app/serializers/spree/api/v3/admin/line_item_serializer.rb
148
151
  - app/serializers/spree/api/v3/admin/metafield_serializer.rb
149
152
  - app/serializers/spree/api/v3/admin/order_serializer.rb
150
153
  - app/serializers/spree/api/v3/admin/price_serializer.rb
@@ -181,8 +184,6 @@ files:
181
184
  - app/serializers/spree/api/v3/payment_session_serializer.rb
182
185
  - app/serializers/spree/api/v3/payment_setup_session_serializer.rb
183
186
  - app/serializers/spree/api/v3/payment_source_serializer.rb
184
- - app/serializers/spree/api/v3/post_category_serializer.rb
185
- - app/serializers/spree/api/v3/post_serializer.rb
186
187
  - app/serializers/spree/api/v3/price_serializer.rb
187
188
  - app/serializers/spree/api/v3/product_serializer.rb
188
189
  - app/serializers/spree/api/v3/promotion_serializer.rb
@@ -222,6 +223,7 @@ files:
222
223
  - lib/spree/api/configuration.rb
223
224
  - lib/spree/api/dependencies.rb
224
225
  - lib/spree/api/engine.rb
226
+ - lib/spree/api/middleware/request_size_limit.rb
225
227
  - lib/spree/api/openapi/schema_helper.rb
226
228
  - lib/spree/api/testing_support/factories.rb
227
229
  - lib/spree/api/testing_support/matchers/webhooks.rb
@@ -233,9 +235,9 @@ licenses:
233
235
  - BSD-3-Clause
234
236
  metadata:
235
237
  bug_tracker_uri: https://github.com/spree/spree/issues
236
- changelog_uri: https://github.com/spree/spree/releases/tag/v5.4.0.beta
238
+ changelog_uri: https://github.com/spree/spree/releases/tag/v5.4.0.beta2
237
239
  documentation_uri: https://docs.spreecommerce.org/
238
- source_code_uri: https://github.com/spree/spree/tree/v5.4.0.beta
240
+ source_code_uri: https://github.com/spree/spree/tree/v5.4.0.beta2
239
241
  post_install_message:
240
242
  rdoc_options: []
241
243
  require_paths:
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Spree
4
- module Api
5
- module V3
6
- class PostCategorySerializer < BaseSerializer
7
- typelize title: :string, slug: :string
8
-
9
- attributes :title, :slug, created_at: :iso8601, updated_at: :iso8601
10
- end
11
- end
12
- end
13
- end
@@ -1,25 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Spree
4
- module Api
5
- module V3
6
- class PostSerializer < BaseSerializer
7
- typelize title: :string, slug: :string,
8
- meta_title: [:string, nullable: true], meta_description: [:string, nullable: true],
9
- published_at: [:string, nullable: true],
10
- author_id: [:string, nullable: true], post_category_id: [:string, nullable: true]
11
-
12
- attributes :title, :slug, :meta_title, :meta_description,
13
- published_at: :iso8601, created_at: :iso8601, updated_at: :iso8601
14
-
15
- attribute :author_id do |post|
16
- post.author&.prefixed_id
17
- end
18
-
19
- attribute :post_category_id do |post|
20
- post.post_category&.prefixed_id
21
- end
22
- end
23
- end
24
- end
25
- end