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.
Files changed (96) 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 +12 -2
  5. data/app/controllers/concerns/spree/api/v3/http_caching.rb +10 -2
  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/rate_limit_headers.rb +31 -0
  10. data/app/controllers/concerns/spree/api/v3/resource_serializer.rb +30 -2
  11. data/app/controllers/concerns/spree/api/v3/security_headers.rb +4 -0
  12. data/app/controllers/spree/api/v3/admin/base_controller.rb +28 -0
  13. data/app/controllers/spree/api/v3/admin/resource_controller.rb +28 -0
  14. data/app/controllers/spree/api/v3/base_controller.rb +20 -1
  15. data/app/controllers/spree/api/v3/resource_controller.rb +35 -3
  16. data/app/controllers/spree/api/v3/store/auth_controller.rb +4 -24
  17. data/app/controllers/spree/api/v3/store/base_controller.rb +0 -11
  18. data/app/controllers/spree/api/v3/store/cart_controller.rb +6 -2
  19. data/app/controllers/spree/api/v3/store/categories/products_controller.rb +37 -0
  20. data/app/controllers/spree/api/v3/store/{taxons_controller.rb → categories_controller.rb} +8 -6
  21. data/app/controllers/spree/api/v3/store/countries_controller.rb +6 -0
  22. data/app/controllers/spree/api/v3/store/currencies_controller.rb +4 -0
  23. data/app/controllers/spree/api/v3/store/customers_controller.rb +102 -0
  24. data/app/controllers/spree/api/v3/store/locales_controller.rb +4 -0
  25. data/app/controllers/spree/api/v3/store/markets/countries_controller.rb +6 -0
  26. data/app/controllers/spree/api/v3/store/markets_controller.rb +8 -0
  27. data/app/controllers/spree/api/v3/store/orders_controller.rb +3 -2
  28. data/app/controllers/spree/api/v3/store/products/filters_controller.rb +9 -8
  29. data/app/controllers/spree/api/v3/store/products_controller.rb +12 -19
  30. data/app/jobs/spree/webhook_delivery_job.rb +4 -1
  31. data/app/serializers/spree/api/v3/admin/address_serializer.rb +24 -0
  32. data/app/serializers/spree/api/v3/admin/adjustment_serializer.rb +36 -0
  33. data/app/serializers/spree/api/v3/admin/admin_user_serializer.rb +15 -0
  34. data/app/serializers/spree/api/v3/admin/asset_serializer.rb +10 -0
  35. data/app/serializers/spree/api/v3/admin/category_serializer.rb +33 -0
  36. data/app/serializers/spree/api/v3/admin/credit_card_serializer.rb +22 -0
  37. data/app/serializers/spree/api/v3/admin/customer_serializer.rb +8 -6
  38. data/app/serializers/spree/api/v3/admin/digital_link_serializer.rb +10 -0
  39. data/app/serializers/spree/api/v3/admin/image_serializer.rb +10 -0
  40. data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +36 -1
  41. data/app/serializers/spree/api/v3/admin/option_type_serializer.rb +13 -0
  42. data/app/serializers/spree/api/v3/admin/option_value_serializer.rb +13 -0
  43. data/app/serializers/spree/api/v3/admin/order_promotion_serializer.rb +10 -0
  44. data/app/serializers/spree/api/v3/admin/order_serializer.rb +47 -6
  45. data/app/serializers/spree/api/v3/admin/payment_method_serializer.rb +14 -0
  46. data/app/serializers/spree/api/v3/admin/payment_serializer.rb +56 -0
  47. data/app/serializers/spree/api/v3/admin/payment_source_serializer.rb +10 -0
  48. data/app/serializers/spree/api/v3/admin/product_serializer.rb +23 -6
  49. data/app/serializers/spree/api/v3/admin/refund_serializer.rb +21 -0
  50. data/app/serializers/spree/api/v3/admin/reimbursement_serializer.rb +13 -0
  51. data/app/serializers/spree/api/v3/admin/return_authorization_serializer.rb +17 -0
  52. data/app/serializers/spree/api/v3/admin/shipment_serializer.rb +44 -0
  53. data/app/serializers/spree/api/v3/admin/shipping_category_serializer.rb +14 -0
  54. data/app/serializers/spree/api/v3/admin/shipping_method_serializer.rb +11 -0
  55. data/app/serializers/spree/api/v3/admin/shipping_rate_serializer.rb +11 -0
  56. data/app/serializers/spree/api/v3/admin/stock_item_serializer.rb +17 -0
  57. data/app/serializers/spree/api/v3/admin/stock_location_serializer.rb +15 -0
  58. data/app/serializers/spree/api/v3/admin/store_credit_serializer.rb +27 -0
  59. data/app/serializers/spree/api/v3/admin/tax_category_serializer.rb +15 -0
  60. data/app/serializers/spree/api/v3/admin/variant_serializer.rb +11 -14
  61. data/app/serializers/spree/api/v3/base_serializer.rb +21 -4
  62. data/app/serializers/spree/api/v3/category_serializer.rb +71 -0
  63. data/app/serializers/spree/api/v3/country_serializer.rb +2 -1
  64. data/app/serializers/spree/api/v3/customer_serializer.rb +2 -1
  65. data/app/serializers/spree/api/v3/gift_card_serializer.rb +5 -21
  66. data/app/serializers/spree/api/v3/order_serializer.rb +2 -2
  67. data/app/serializers/spree/api/v3/payment_serializer.rb +1 -1
  68. data/app/serializers/spree/api/v3/product_serializer.rb +11 -11
  69. data/app/serializers/spree/api/v3/shipping_category_serializer.rb +11 -0
  70. data/app/serializers/spree/api/v3/shipping_rate_serializer.rb +2 -6
  71. data/app/serializers/spree/api/v3/tax_category_serializer.rb +11 -0
  72. data/app/serializers/spree/api/v3/variant_serializer.rb +4 -4
  73. data/app/serializers/spree/api/v3/wishlist_serializer.rb +1 -1
  74. data/app/services/spree/api/v3/filters_aggregator.rb +31 -25
  75. data/app/services/spree/webhooks/deliver_webhook.rb +23 -17
  76. data/app/subscribers/spree/webhook_event_subscriber.rb +1 -1
  77. data/config/initializers/alba.rb +1 -1
  78. data/config/initializers/typelizer.rb +26 -16
  79. data/config/locales/en.yml +1 -0
  80. data/config/routes.rb +9 -9
  81. data/lib/spree/api/configuration.rb +3 -1
  82. data/lib/spree/api/dependencies.rb +28 -5
  83. data/lib/spree/api/engine.rb +15 -0
  84. data/lib/spree/api/openapi/schema_helper.rb +27 -7
  85. data/lib/spree/api/testing_support/v3/base.rb +24 -1
  86. metadata +43 -19
  87. data/app/controllers/spree/api/v3/store/customer/account_controller.rb +0 -38
  88. data/app/controllers/spree/api/v3/store/stores_controller.rb +0 -26
  89. data/app/controllers/spree/api/v3/store/taxonomies_controller.rb +0 -19
  90. data/app/controllers/spree/api/v3/store/taxons/products_controller.rb +0 -37
  91. data/app/serializers/spree/api/v3/admin/taxon_serializer.rb +0 -20
  92. data/app/serializers/spree/api/v3/admin/taxonomy_serializer.rb +0 -15
  93. data/app/serializers/spree/api/v3/store_serializer.rb +0 -38
  94. data/app/serializers/spree/api/v3/taxon_serializer.rb +0 -78
  95. data/app/serializers/spree/api/v3/taxonomy_serializer.rb +0 -33
  96. 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: e8154a8dd735a006cee415621ff4d95bfdfb4f30935e139bcc72b3d665868ae3
4
- data.tar.gz: d013be2887432f12fd2dc3440aa14d7fd5d831ebdedc4f814c93c6925dc6b450
3
+ metadata.gz: 03df105b93ed4bea7174ff452d0b35024cb242b032bf2e546d4bdabab30197d4
4
+ data.tar.gz: 4259a95154f7fbf1a4921a2b7985659b74a1466395e9b72bc6b8bfc5190105ce
5
5
  SHA512:
6
- metadata.gz: ec930921c59d1e904c643ae363efa3cd40c634567ab7a1daf696e35014efae10e6197c0b3f657d0ff537a05ed36b165e34316eb11a25a83fe2d5352793d0afb2
7
- data.tar.gz: 92175956237d07cbf778b29d7bb2bd562f0e39cbef764d9f608e01c69c849fdc648727750f52a2370d228a8e79df7080d1c84e2802f585ac1ca2303a36d8acb6
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',
@@ -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
- model_name = extract_model_name(exception)
241
- 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
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
- collection.map(&:updated_at).max&.to_i,
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
- 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,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
@@ -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, limit: limit, page: page)
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: 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
@@ -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 TaxonsController < ResourceController
5
+ class CategoriesController < ResourceController
6
+ include Spree::Api::V3::HttpCaching
7
+
6
8
  protected
7
9
 
8
10
  def model_class
9
- Spree::Taxon
11
+ Spree::Category
10
12
  end
11
13
 
12
14
  def serializer_class
13
- Spree.api.taxon_serializer
15
+ Spree.api.category_serializer
14
16
  end
15
17
 
16
- # Find taxon by permalink or prefixed ID with i18n scope for SEO-friendly URLs
17
- # Falls back to default locale if taxon is not found in the current locale
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?('txn_')
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
  }