spree_api 5.4.3 → 5.5.0.rc1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Rakefile +19 -0
- data/app/controllers/concerns/spree/api/v3/admin/auth_cookies.rb +62 -0
- data/app/controllers/concerns/spree/api/v3/admin/subclassed_resource.rb +149 -0
- data/app/controllers/concerns/spree/api/v3/admin_authentication.rb +54 -0
- data/app/controllers/concerns/spree/api/v3/bulk_operations.rb +103 -0
- data/app/controllers/concerns/spree/api/v3/channel_resolution.rb +60 -0
- data/app/controllers/concerns/spree/api/v3/error_handler.rb +4 -0
- data/app/controllers/concerns/spree/api/v3/params_normalizer.rb +84 -0
- data/app/controllers/concerns/spree/api/v3/scoped_authorization.rb +88 -0
- data/app/controllers/concerns/spree/api/v3/store/search_provider_support.rb +35 -1
- data/app/controllers/spree/api/v3/admin/admin_users_controller.rb +97 -0
- data/app/controllers/spree/api/v3/admin/allowed_origins_controller.rb +25 -0
- data/app/controllers/spree/api/v3/admin/api_keys_controller.rb +55 -0
- data/app/controllers/spree/api/v3/admin/auth_controller.rb +134 -0
- data/app/controllers/spree/api/v3/admin/base_controller.rb +3 -17
- data/app/controllers/spree/api/v3/admin/categories_controller.rb +25 -0
- data/app/controllers/spree/api/v3/admin/channels_controller.rb +65 -0
- data/app/controllers/spree/api/v3/admin/countries_controller.rb +38 -0
- data/app/controllers/spree/api/v3/admin/coupon_codes_controller.rb +33 -0
- data/app/controllers/spree/api/v3/admin/custom_field_definitions_controller.rb +34 -0
- data/app/controllers/spree/api/v3/admin/custom_fields_controller.rb +108 -0
- data/app/controllers/spree/api/v3/admin/customer_groups_controller.rb +31 -0
- data/app/controllers/spree/api/v3/admin/customers/addresses_controller.rb +88 -0
- data/app/controllers/spree/api/v3/admin/customers/credit_cards_controller.rb +31 -0
- data/app/controllers/spree/api/v3/admin/customers/store_credits_controller.rb +93 -0
- data/app/controllers/spree/api/v3/admin/customers_controller.rb +119 -0
- data/app/controllers/spree/api/v3/admin/dashboard_controller.rb +44 -0
- data/app/controllers/spree/api/v3/admin/direct_uploads_controller.rb +40 -0
- data/app/controllers/spree/api/v3/admin/exports_controller.rb +89 -0
- data/app/controllers/spree/api/v3/admin/gift_card_batches_controller.rb +31 -0
- data/app/controllers/spree/api/v3/admin/gift_cards_controller.rb +33 -0
- data/app/controllers/spree/api/v3/admin/invitation_acceptances_controller.rb +138 -0
- data/app/controllers/spree/api/v3/admin/invitations_controller.rb +70 -0
- data/app/controllers/spree/api/v3/admin/markets_controller.rb +42 -0
- data/app/controllers/spree/api/v3/admin/me_controller.rb +69 -0
- data/app/controllers/spree/api/v3/admin/media_controller.rb +119 -0
- data/app/controllers/spree/api/v3/admin/option_types_controller.rb +34 -0
- data/app/controllers/spree/api/v3/admin/orders/adjustments_controller.rb +27 -0
- data/app/controllers/spree/api/v3/admin/orders/base_controller.rb +26 -0
- data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +104 -0
- data/app/controllers/spree/api/v3/admin/orders/gift_cards_controller.rb +79 -0
- data/app/controllers/spree/api/v3/admin/orders/items_controller.rb +92 -0
- data/app/controllers/spree/api/v3/admin/orders/payments_controller.rb +90 -0
- data/app/controllers/spree/api/v3/admin/orders/refunds_controller.rb +53 -0
- data/app/controllers/spree/api/v3/admin/orders/store_credits_controller.rb +59 -0
- data/app/controllers/spree/api/v3/admin/orders_controller.rb +190 -0
- data/app/controllers/spree/api/v3/admin/payment_methods_controller.rb +73 -0
- data/app/controllers/spree/api/v3/admin/price_lists_controller.rb +156 -0
- data/app/controllers/spree/api/v3/admin/prices_controller.rb +129 -0
- data/app/controllers/spree/api/v3/admin/products/variants_controller.rb +48 -0
- data/app/controllers/spree/api/v3/admin/products_controller.rb +237 -0
- data/app/controllers/spree/api/v3/admin/promotion_actions_controller.rb +78 -0
- data/app/controllers/spree/api/v3/admin/promotion_rules_controller.rb +56 -0
- data/app/controllers/spree/api/v3/admin/promotions_controller.rb +78 -0
- data/app/controllers/spree/api/v3/admin/resource_controller.rb +29 -11
- data/app/controllers/spree/api/v3/admin/roles_controller.rb +29 -0
- data/app/controllers/spree/api/v3/admin/stock_items_controller.rb +35 -0
- data/app/controllers/spree/api/v3/admin/stock_locations_controller.rb +36 -0
- data/app/controllers/spree/api/v3/admin/stock_reservations_controller.rb +29 -0
- data/app/controllers/spree/api/v3/admin/stock_transfers_controller.rb +75 -0
- data/app/controllers/spree/api/v3/admin/store_controller.rb +53 -0
- data/app/controllers/spree/api/v3/admin/store_credit_categories_controller.rb +21 -0
- data/app/controllers/spree/api/v3/admin/tags_controller.rb +51 -0
- data/app/controllers/spree/api/v3/admin/tax_categories_controller.rb +21 -0
- data/app/controllers/spree/api/v3/admin/variants_controller.rb +33 -0
- data/app/controllers/spree/api/v3/admin/webhook_deliveries_controller.rb +49 -0
- data/app/controllers/spree/api/v3/admin/webhook_endpoints_controller.rb +75 -0
- data/app/controllers/spree/api/v3/resource_controller.rb +90 -8
- data/app/controllers/spree/api/v3/store/auth_controller.rb +8 -28
- data/app/controllers/spree/api/v3/store/base_controller.rb +6 -0
- data/app/controllers/spree/api/v3/store/carts_controller.rb +1 -0
- data/app/controllers/spree/api/v3/store/customers_controller.rb +6 -0
- data/app/controllers/spree/api/v3/store/newsletter_subscribers_controller.rb +77 -0
- data/app/controllers/spree/api/v3/store/products/filters_controller.rb +2 -2
- data/app/controllers/spree/api/v3/store/products_controller.rb +3 -3
- data/app/controllers/spree/api/v3/store/resource_controller.rb +10 -2
- data/app/jobs/spree/webhook_delivery_job.rb +5 -0
- data/app/models/spree/api_key_ability.rb +16 -0
- data/app/serializers/spree/api/v3/admin/address_serializer.rb +2 -6
- data/app/serializers/spree/api/v3/admin/adjustment_serializer.rb +3 -15
- data/app/serializers/spree/api/v3/admin/admin_user_serializer.rb +19 -3
- data/app/serializers/spree/api/v3/admin/allowed_origin_serializer.rb +2 -6
- data/app/serializers/spree/api/v3/admin/api_key_serializer.rb +42 -0
- data/app/serializers/spree/api/v3/admin/category_serializer.rb +4 -3
- data/app/serializers/spree/api/v3/admin/channel_serializer.rb +15 -0
- data/app/serializers/spree/api/v3/admin/country_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/coupon_code_serializer.rb +30 -0
- data/app/serializers/spree/api/v3/admin/credit_card_serializer.rb +4 -2
- data/app/serializers/spree/api/v3/admin/custom_field_definition_serializer.rb +21 -0
- data/app/serializers/spree/api/v3/admin/custom_field_serializer.rb +8 -3
- data/app/serializers/spree/api/v3/admin/customer_group_serializer.rb +27 -0
- data/app/serializers/spree/api/v3/admin/customer_serializer.rb +58 -2
- data/app/serializers/spree/api/v3/admin/dashboard_analytics_serializer.rb +143 -0
- data/app/serializers/spree/api/v3/admin/export_serializer.rb +40 -0
- data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +2 -6
- data/app/serializers/spree/api/v3/admin/{asset_serializer.rb → gift_card_batch_serializer.rb} +1 -1
- data/app/serializers/spree/api/v3/admin/gift_card_serializer.rb +39 -4
- data/app/serializers/spree/api/v3/admin/invitation_serializer.rb +64 -0
- data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +4 -16
- data/app/serializers/spree/api/v3/admin/media_serializer.rb +24 -2
- data/app/serializers/spree/api/v3/admin/option_type_serializer.rb +4 -1
- data/app/serializers/spree/api/v3/admin/option_value_serializer.rb +4 -1
- data/app/serializers/spree/api/v3/admin/order_serializer.rb +21 -6
- data/app/serializers/spree/api/v3/admin/payment_method_serializer.rb +11 -2
- data/app/serializers/spree/api/v3/admin/payment_serializer.rb +2 -6
- data/app/serializers/spree/api/v3/admin/payment_source_serializer.rb +4 -1
- data/app/serializers/spree/api/v3/admin/price_list_serializer.rb +51 -0
- data/app/serializers/spree/api/v3/admin/price_rule_serializer.rb +55 -0
- data/app/serializers/spree/api/v3/admin/price_serializer.rb +4 -0
- data/app/serializers/spree/api/v3/admin/product_publication_serializer.rb +11 -0
- data/app/serializers/spree/api/v3/admin/product_serializer.rb +34 -10
- data/app/serializers/spree/api/v3/admin/promotion_action_serializer.rb +71 -0
- data/app/serializers/spree/api/v3/admin/promotion_rule_serializer.rb +85 -0
- data/app/serializers/spree/api/v3/admin/promotion_serializer.rb +41 -0
- data/app/serializers/spree/api/v3/admin/refund_serializer.rb +4 -2
- data/app/serializers/spree/api/v3/admin/role_serializer.rb +17 -0
- data/app/serializers/spree/api/v3/admin/stock_item_serializer.rb +16 -1
- data/app/serializers/spree/api/v3/admin/stock_location_serializer.rb +11 -2
- data/app/serializers/spree/api/v3/admin/stock_reservation_serializer.rb +46 -0
- data/app/serializers/spree/api/v3/admin/stock_transfer_serializer.rb +37 -0
- data/app/serializers/spree/api/v3/admin/store_credit_category_serializer.rb +19 -0
- data/app/serializers/spree/api/v3/admin/store_credit_serializer.rb +11 -5
- data/app/serializers/spree/api/v3/admin/store_serializer.rb +55 -0
- data/app/serializers/spree/api/v3/admin/tax_category_serializer.rb +4 -2
- data/app/serializers/spree/api/v3/admin/variant_serializer.rb +37 -6
- data/app/serializers/spree/api/v3/admin/webhook_delivery_serializer.rb +45 -0
- data/app/serializers/spree/api/v3/admin/webhook_endpoint_serializer.rb +69 -0
- data/app/serializers/spree/api/v3/channel_serializer.rb +14 -0
- data/app/serializers/spree/api/v3/custom_field_serializer.rb +9 -10
- data/app/serializers/spree/api/v3/customer_serializer.rb +5 -0
- data/app/serializers/spree/api/v3/market_serializer.rb +2 -1
- data/app/serializers/spree/api/v3/media_serializer.rb +8 -6
- data/app/serializers/spree/api/v3/order_serializer.rb +6 -1
- data/app/serializers/spree/api/v3/payment_method_serializer.rb +11 -2
- data/app/serializers/spree/api/v3/product_publication_serializer.rb +22 -0
- data/app/serializers/spree/api/v3/product_serializer.rb +6 -1
- data/app/serializers/spree/api/v3/stock_reservation_serializer.rb +10 -0
- data/config/locales/en.yml +2 -0
- data/config/routes.rb +235 -1
- data/lib/spree/api/configuration.rb +2 -2
- data/lib/spree/api/dependencies.rb +25 -1
- data/lib/spree/api/openapi/path_sorter.rb +126 -0
- data/lib/spree/api/openapi/schema_helper.rb +185 -6
- metadata +96 -8
- data/app/serializers/spree/api/v3/admin/shipping_category_serializer.rb +0 -14
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 34c425ef2a24c388679efdce7af49e3a00df8ec545d54571d11623b35ca0a130
|
|
4
|
+
data.tar.gz: eda87fa654dbf4789c82ea441a4ddcf66971d91323904ad71ca68ef0d30292a5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2c89bedcfd4322921097213e93c2cfb8308c0dbbd960b848c21d625d42b85cf6f845311b56dd02b06e6c392fb38680796cfa1f8e74dc52d135cb870e13c062ac
|
|
7
|
+
data.tar.gz: f55d6b06ad6db697192f6b1678833062c3fbb3ad05d429c523a5dc06896b87fa2c8ab2b66300041eb8bf07befb2b2bc42eda2f569cb6f8760984c5626a6ea2d6
|
data/Rakefile
CHANGED
|
@@ -27,6 +27,25 @@ namespace :rswag do
|
|
|
27
27
|
ENV['OPENAPI'] = 'true'
|
|
28
28
|
t.rspec_opts = ['--format Rswag::Specs::SwaggerFormatter', '--order defined']
|
|
29
29
|
end
|
|
30
|
+
|
|
31
|
+
# rswag emits paths in spec-load order (alphabetical by filename), which doesn't
|
|
32
|
+
# match the curated order in swagger_helper.rb's `tags:` array. Mintlify groups
|
|
33
|
+
# sidebar sections by first-tag-appearance in `paths:`, so reorder paths here
|
|
34
|
+
# so the sidebar follows the `tags:` array.
|
|
35
|
+
Rake::Task['rswag:specs:swaggerize'].enhance do
|
|
36
|
+
$LOAD_PATH.unshift(File.expand_path('lib', __dir__))
|
|
37
|
+
require 'spree/api/openapi/path_sorter'
|
|
38
|
+
|
|
39
|
+
docs = File.expand_path('../../docs/api-reference', __dir__)
|
|
40
|
+
%w[admin.yaml store.yaml].each do |name|
|
|
41
|
+
path = File.join(docs, name)
|
|
42
|
+
next unless File.exist?(path)
|
|
43
|
+
|
|
44
|
+
if Spree::Api::OpenAPI::PathSorter.sort_file!(path)
|
|
45
|
+
puts "Reordered paths by tag → #{path}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
30
49
|
end
|
|
31
50
|
end
|
|
32
51
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V3
|
|
4
|
+
module Admin
|
|
5
|
+
# Cookie-based delivery for admin refresh tokens.
|
|
6
|
+
#
|
|
7
|
+
# Refresh token: HttpOnly signed cookie at /api/v3/admin/auth — invisible to JS,
|
|
8
|
+
# tamper-evident via Rails' cookie signing.
|
|
9
|
+
#
|
|
10
|
+
# CSRF protection:
|
|
11
|
+
# We deliberately do NOT use a CSRF token here. The threat model is fully
|
|
12
|
+
# covered by the combination of:
|
|
13
|
+
# - SameSite=Lax (dev) / SameSite=None; Secure (prod) on the refresh cookie
|
|
14
|
+
# - Spree::AllowedOrigin allowlist enforced via Rack::Cors with credentials: true
|
|
15
|
+
# - CORS preflight blocking cross-origin requests from non-allowlisted Origins
|
|
16
|
+
# A double-submit CSRF token would only add value if the AllowedOrigin allowlist
|
|
17
|
+
# were misconfigured or if an XSS happened on a different allowlisted origin —
|
|
18
|
+
# both scenarios where a defender's deeper problem outweighs CSRF mitigation.
|
|
19
|
+
# See docs/plans/5.5-admin-auth-cookie-refresh.md for the full reasoning.
|
|
20
|
+
module AuthCookies
|
|
21
|
+
extend ActiveSupport::Concern
|
|
22
|
+
|
|
23
|
+
# ActionController::API drops Cookies — re-include it on the auth controller only.
|
|
24
|
+
# Rest of the admin API stays cookie-free and stateless.
|
|
25
|
+
included do
|
|
26
|
+
include ActionController::Cookies
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
REFRESH_COOKIE_NAME = :spree_admin_refresh_token
|
|
30
|
+
COOKIE_PATH = '/api/v3/admin/auth'.freeze
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def set_refresh_cookie(refresh_token)
|
|
35
|
+
cookies.signed[REFRESH_COOKIE_NAME] = base_cookie_attributes.merge(
|
|
36
|
+
value: refresh_token.token,
|
|
37
|
+
expires: refresh_token.expires_at,
|
|
38
|
+
path: COOKIE_PATH,
|
|
39
|
+
httponly: true
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def clear_refresh_cookie
|
|
44
|
+
cookies.delete(REFRESH_COOKIE_NAME, path: COOKIE_PATH)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def refresh_token_from_cookie
|
|
48
|
+
cookies.signed[REFRESH_COOKIE_NAME].presence
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def base_cookie_attributes
|
|
52
|
+
if Rails.env.production?
|
|
53
|
+
{ secure: true, same_site: :none }
|
|
54
|
+
else
|
|
55
|
+
{ secure: false, same_site: :lax }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V3
|
|
4
|
+
module Admin
|
|
5
|
+
# Shared `create` / `update` flow for STI parents whose subclass is
|
|
6
|
+
# picked at request time and whose configuration lives in a
|
|
7
|
+
# `preferences` hash (PaymentMethod, PromotionAction, PromotionRule).
|
|
8
|
+
#
|
|
9
|
+
# Including controllers declare:
|
|
10
|
+
#
|
|
11
|
+
# subclassed_via -> { Spree::PaymentMethod.providers },
|
|
12
|
+
# unknown_type_error: 'unknown_payment_method_type'
|
|
13
|
+
#
|
|
14
|
+
# The body picks the subclass against the registry (returns 422 with
|
|
15
|
+
# the configured error code on miss), strips `type`/`preferences`
|
|
16
|
+
# from the permitted attrs, builds/assigns the rest, and routes
|
|
17
|
+
# preference values through the typed `preferred_<name>=` setters
|
|
18
|
+
# so booleans/decimals/etc. get coerced. Unknown preference keys
|
|
19
|
+
# are silently dropped — the schema endpoint is the source of truth
|
|
20
|
+
# for what's settable.
|
|
21
|
+
module SubclassedResource
|
|
22
|
+
extend ActiveSupport::Concern
|
|
23
|
+
|
|
24
|
+
class_methods do
|
|
25
|
+
def subclassed_via(registry, unknown_type_error:)
|
|
26
|
+
@subclass_registry = registry
|
|
27
|
+
@unknown_type_error_code = unknown_type_error
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def subclass_registry
|
|
31
|
+
@subclass_registry || (superclass.respond_to?(:subclass_registry) ? superclass.subclass_registry : nil)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def unknown_type_error_code
|
|
35
|
+
@unknown_type_error_code ||
|
|
36
|
+
(superclass.respond_to?(:unknown_type_error_code) ? superclass.unknown_type_error_code : nil)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def create
|
|
41
|
+
klass = resolve_subclass(params[:type])
|
|
42
|
+
return render_unknown_type unless klass
|
|
43
|
+
|
|
44
|
+
permitted = permitted_params_for(klass)
|
|
45
|
+
attrs, preferences, calculator = extract_subclass_params(permitted)
|
|
46
|
+
|
|
47
|
+
@resource = build_subclassed_resource(klass, attrs)
|
|
48
|
+
apply_preferences(@resource, preferences) if preferences.present?
|
|
49
|
+
apply_calculator(@resource, calculator) if calculator.present?
|
|
50
|
+
authorize_resource!(@resource, :create)
|
|
51
|
+
|
|
52
|
+
if @resource.save
|
|
53
|
+
render json: serialize_resource(@resource), status: :created
|
|
54
|
+
else
|
|
55
|
+
render_validation_error(@resource.errors)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def update
|
|
60
|
+
@resource = find_resource
|
|
61
|
+
authorize_resource!(@resource, :update)
|
|
62
|
+
|
|
63
|
+
permitted = permitted_params_for(@resource.class)
|
|
64
|
+
attrs, preferences, calculator = extract_subclass_params(permitted)
|
|
65
|
+
|
|
66
|
+
@resource.assign_attributes(attrs)
|
|
67
|
+
apply_preferences(@resource, preferences) if preferences.present?
|
|
68
|
+
apply_calculator(@resource, calculator) if calculator.present?
|
|
69
|
+
|
|
70
|
+
if @resource.save
|
|
71
|
+
render json: serialize_resource(@resource)
|
|
72
|
+
else
|
|
73
|
+
render_validation_error(@resource.errors)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Per-subclass permitted params. Calls `permitted_params` (the base
|
|
80
|
+
# allowlist with `type` + `preferences`) and merges in extras the
|
|
81
|
+
# subclass has declared via `additional_permitted_attributes`.
|
|
82
|
+
# Controllers can override this hook directly if their subclasses
|
|
83
|
+
# expose extras through a different mechanism.
|
|
84
|
+
def permitted_params_for(klass)
|
|
85
|
+
extras = klass.respond_to?(:additional_permitted_attributes) ? klass.additional_permitted_attributes : []
|
|
86
|
+
return permitted_params if extras.blank?
|
|
87
|
+
|
|
88
|
+
params.permit(:type, { preferences: {} }, *extras)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Default build: top-level resource. Nested controllers (actions,
|
|
92
|
+
# rules) override to attach the parent.
|
|
93
|
+
def build_subclassed_resource(klass, attrs)
|
|
94
|
+
klass.new(attrs)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def resolve_subclass(type_name)
|
|
98
|
+
return nil if type_name.blank?
|
|
99
|
+
|
|
100
|
+
self.class.subclass_registry.call.find { |klass| klass.api_type == type_name.to_s }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def render_unknown_type
|
|
104
|
+
render_error(
|
|
105
|
+
code: self.class.unknown_type_error_code,
|
|
106
|
+
message: Spree.t("api.#{self.class.unknown_type_error_code}",
|
|
107
|
+
default: 'Unknown type'),
|
|
108
|
+
status: :unprocessable_content
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def apply_preferences(resource, preferences)
|
|
113
|
+
password_keys = resource.password_preference_keys
|
|
114
|
+
|
|
115
|
+
preferences.each do |key, value|
|
|
116
|
+
pref_name = key.to_sym
|
|
117
|
+
next unless resource.has_preference?(pref_name)
|
|
118
|
+
# Round-trip guard: clients fetching a record see masked
|
|
119
|
+
# `:password` values. Submitting the mask back unchanged
|
|
120
|
+
# must NOT overwrite the real secret with `••••cret`.
|
|
121
|
+
next if password_keys.include?(pref_name) && Spree::Preferences::Masking.masked?(value)
|
|
122
|
+
|
|
123
|
+
resource.set_preference(pref_name, value)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Pulls `preferences` and `calculator` out of the permitted
|
|
128
|
+
# params so they can be routed through their typed setters
|
|
129
|
+
# (`set_preference`, `assign_calculator_attributes`) instead
|
|
130
|
+
# of generic `assign_attributes`. The remaining hash is
|
|
131
|
+
# safe to pass through as plain attribute assignments.
|
|
132
|
+
def extract_subclass_params(permitted)
|
|
133
|
+
permitted = permitted.respond_to?(:to_unsafe_h) ? permitted.to_unsafe_h.with_indifferent_access : permitted.with_indifferent_access
|
|
134
|
+
permitted.delete(:type) # subclass is already resolved; don't let it overwrite STI column
|
|
135
|
+
preferences = permitted.delete(:preferences)
|
|
136
|
+
calculator = permitted.delete(:calculator)
|
|
137
|
+
[permitted, preferences, calculator]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def apply_calculator(resource, calculator)
|
|
141
|
+
return unless resource.respond_to?(:assign_calculator_attributes)
|
|
142
|
+
|
|
143
|
+
resource.assign_calculator_attributes(calculator)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V3
|
|
4
|
+
module AdminAuthentication
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
after_action :set_no_store_cache
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
protected
|
|
12
|
+
|
|
13
|
+
# Override JWT audience to require admin tokens
|
|
14
|
+
def expected_audience
|
|
15
|
+
Spree::Api::V3::JwtAuthentication::JWT_AUDIENCE_ADMIN
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# API-key-only requests bypass CanCanCan: the ScopedAuthorization
|
|
19
|
+
# concern is the authoritative gate (read_/write_ scopes per resource).
|
|
20
|
+
# JWT admin users keep CanCanCan abilities; if both credentials are
|
|
21
|
+
# present, the JWT user wins for permission resolution.
|
|
22
|
+
def current_ability
|
|
23
|
+
return super if current_user
|
|
24
|
+
return super unless @current_api_key
|
|
25
|
+
|
|
26
|
+
@current_ability ||= Spree::ApiKeyAbility.new(ability_options)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Authenticates admin requests via secret API key OR JWT token.
|
|
30
|
+
# Secret keys are checked first (server-to-server integrations),
|
|
31
|
+
# then JWT tokens (admin SPA sessions).
|
|
32
|
+
def authenticate_admin!
|
|
33
|
+
# Try secret API key first
|
|
34
|
+
@current_api_key = Spree::ApiKey.find_by_secret_token(extract_api_key)
|
|
35
|
+
@current_api_key = nil if @current_api_key && @current_api_key.store_id != current_store.id
|
|
36
|
+
|
|
37
|
+
if @current_api_key
|
|
38
|
+
touch_api_key_if_needed(@current_api_key)
|
|
39
|
+
return true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Fall back to JWT authentication
|
|
43
|
+
require_authentication!
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def set_no_store_cache
|
|
49
|
+
response.headers['Cache-Control'] = 'private, no-store'
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V3
|
|
4
|
+
# API-side counterpart to `Spree::Admin::BulkOperationsConcern`. Provides
|
|
5
|
+
# the shared tag bulk actions, the `bulk_collection` relation, and the
|
|
6
|
+
# `after_bulk_tags_change` hook controllers override (e.g. to reindex
|
|
7
|
+
# search + match automatic taxons). Shape mirrors the legacy concern so
|
|
8
|
+
# the two stay easy to keep in sync.
|
|
9
|
+
module BulkOperations
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
included do
|
|
13
|
+
# Guards the concern's two actions; host controllers add their own
|
|
14
|
+
# bulk actions to the filter with `before_action :require_ids!, only: [...]`.
|
|
15
|
+
before_action :require_ids!, only: [:bulk_add_tags, :bulk_remove_tags]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# POST /api/v3/admin/<resource>/bulk_add_tags
|
|
19
|
+
# Body: { ids: [...], tags: ['summer', 'sale'] }
|
|
20
|
+
def bulk_add_tags
|
|
21
|
+
authorize! :update, model_class
|
|
22
|
+
|
|
23
|
+
Spree::Tags::BulkAdd.call(tag_names: Array(params[:tags]), records: bulk_collection)
|
|
24
|
+
after_bulk_tags_change
|
|
25
|
+
|
|
26
|
+
render json: bulk_tags_response
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# POST /api/v3/admin/<resource>/bulk_remove_tags
|
|
30
|
+
# Body: { ids: [...], tags: ['summer', 'sale'] }
|
|
31
|
+
def bulk_remove_tags
|
|
32
|
+
authorize! :update, model_class
|
|
33
|
+
|
|
34
|
+
Spree::Tags::BulkRemove.call(tag_names: Array(params[:tags]), records: bulk_collection)
|
|
35
|
+
after_bulk_tags_change
|
|
36
|
+
|
|
37
|
+
render json: bulk_tags_response
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# 422s when the caller omits `ids` entirely. An explicit empty array
|
|
43
|
+
# is allowed (= no-op, useful when a UI clears its selection between
|
|
44
|
+
# render and submit); a missing key is treated as a client bug since
|
|
45
|
+
# bulk endpoints would otherwise silently match no rows and the
|
|
46
|
+
# caller couldn't tell the difference from a successful zero-match.
|
|
47
|
+
def require_ids!
|
|
48
|
+
return if params.key?(:ids)
|
|
49
|
+
|
|
50
|
+
render_error(
|
|
51
|
+
code: 'missing_ids',
|
|
52
|
+
message: 'ids is required (send an empty array to no-op).',
|
|
53
|
+
status: :unprocessable_content
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Hook for controllers to perform additional work after bulk tag
|
|
58
|
+
# mutations — e.g. enqueueing search reindex jobs, re-matching
|
|
59
|
+
# automatic taxons. Mirrors `Spree::Admin::BulkOperationsConcern`.
|
|
60
|
+
def after_bulk_tags_change
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Slim ability-scoped relation targeted by the inbound `ids` param.
|
|
64
|
+
# Cross-store IDs and IDs the current admin can't update are silently
|
|
65
|
+
# dropped before any mutation runs. Mirrors the legacy concern's
|
|
66
|
+
# `bulk_collection` — no eager loads, because bulk endpoints only need
|
|
67
|
+
# `id` + `store_ids` per record.
|
|
68
|
+
def bulk_collection
|
|
69
|
+
@bulk_collection ||= begin
|
|
70
|
+
product_ids = decode_ids(params[:ids])
|
|
71
|
+
model_class.for_store(current_store).accessible_by(current_ability, :update)
|
|
72
|
+
.where(id: product_ids)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Default shape for bulk tag responses; controllers override
|
|
77
|
+
# `bulk_record_count_key` to give the count its resource-named JSON key
|
|
78
|
+
# (e.g. `product_count`, `order_count`). Mirrors how the legacy admin
|
|
79
|
+
# concern delegates response shaping to the host controller.
|
|
80
|
+
def bulk_tags_response
|
|
81
|
+
{
|
|
82
|
+
bulk_record_count_key => bulk_collection.size,
|
|
83
|
+
tag_count: Array(params[:tags]).size
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def bulk_record_count_key
|
|
88
|
+
:record_count
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Maps inbound IDs (a mix of prefixed `prod_…` strings and raw IDs) to
|
|
92
|
+
# raw IDs. Anything that isn't a valid prefixed ID is passed through
|
|
93
|
+
# verbatim, so legacy clients sending raw IDs keep working. Prefix
|
|
94
|
+
# decoding is pure — no DB lookup needed.
|
|
95
|
+
def decode_ids(ids)
|
|
96
|
+
Array(ids).map do |id|
|
|
97
|
+
Spree::PrefixedId.prefixed_id?(id) ? Spree::PrefixedId.decode_prefixed_id(id) : id
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V3
|
|
4
|
+
# Resolves the active Spree::Channel for an API request and writes it
|
|
5
|
+
# into +Spree::Current.channel+ so models, scopes, and serializers can
|
|
6
|
+
# read channel context without threading it through method args.
|
|
7
|
+
#
|
|
8
|
+
# Resolution order:
|
|
9
|
+
# 1. +X-Spree-Channel+ header value matched against +channels.code+ —
|
|
10
|
+
# or, if it looks like a prefixed ID (+ch_…+), against +channels.id+
|
|
11
|
+
# — scoped to the current store
|
|
12
|
+
# 2. +current_store.default_channel+
|
|
13
|
+
#
|
|
14
|
+
# The concern is a no-op if no channel matches — callers fall back to
|
|
15
|
+
# +Spree::Current.channel+'s store-default behavior.
|
|
16
|
+
module ChannelResolution
|
|
17
|
+
extend ActiveSupport::Concern
|
|
18
|
+
|
|
19
|
+
CHANNEL_HEADER = 'X-Spree-Channel'.freeze
|
|
20
|
+
|
|
21
|
+
included do
|
|
22
|
+
before_action :set_current_channel
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
protected
|
|
26
|
+
|
|
27
|
+
def current_channel
|
|
28
|
+
@current_channel ||= channel_from_header || Spree::Current.channel
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Only write to Spree::Current when the header resolves a specific
|
|
34
|
+
# channel. The store-default fallback is handled lazily by
|
|
35
|
+
# +Spree::Current.channel+ itself, which avoids one query per
|
|
36
|
+
# header-less API request.
|
|
37
|
+
def set_current_channel
|
|
38
|
+
channel = channel_from_header
|
|
39
|
+
Spree::Current.channel = channel if channel
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def channel_from_header
|
|
43
|
+
value = request.headers[CHANNEL_HEADER].presence
|
|
44
|
+
return nil if value.blank?
|
|
45
|
+
return nil unless current_store
|
|
46
|
+
|
|
47
|
+
scope = current_store.channels.active
|
|
48
|
+
# Accept either a merchant-meaningful +code+ ("pos", "wholesale") or
|
|
49
|
+
# the opaque prefixed ID — mirrors how Store API endpoints accept
|
|
50
|
+
# either slug or prefixed ID (e.g. +products/{slug-or-id}+).
|
|
51
|
+
if Spree::PrefixedId.prefixed_id?(value)
|
|
52
|
+
scope.find_by_prefix_id(value)
|
|
53
|
+
else
|
|
54
|
+
scope.find_by(code: value)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -31,6 +31,7 @@ module Spree
|
|
|
31
31
|
|
|
32
32
|
# Order errors
|
|
33
33
|
order_not_found: 'order_not_found',
|
|
34
|
+
order_cannot_complete: 'order_cannot_complete',
|
|
34
35
|
|
|
35
36
|
# Line item errors
|
|
36
37
|
line_item_not_found: 'line_item_not_found',
|
|
@@ -59,6 +60,9 @@ module Spree
|
|
|
59
60
|
digital_link_expired: 'digital_link_expired',
|
|
60
61
|
download_limit_exceeded: 'download_limit_exceeded',
|
|
61
62
|
|
|
63
|
+
# Export errors
|
|
64
|
+
export_not_ready: 'export_not_ready',
|
|
65
|
+
|
|
62
66
|
# Rate limiting errors
|
|
63
67
|
rate_limit_exceeded: 'rate_limit_exceeded',
|
|
64
68
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V3
|
|
4
|
+
# Normalizes flat API v3 JSON params for Rails model consumption:
|
|
5
|
+
#
|
|
6
|
+
# 1. **Prefixed ID resolution** — decodes Stripe-style prefixed IDs (e.g. "prod_86Rf07xd4z")
|
|
7
|
+
# to integer primary keys for any param ending in `_id` or `_ids`.
|
|
8
|
+
#
|
|
9
|
+
# 2. **Nested attributes normalization** — converts flat arrays (e.g. `taxon_rules: [...]`)
|
|
10
|
+
# to Rails `_attributes` format (e.g. `taxon_rules_attributes: [...]`) based on
|
|
11
|
+
# the model's `accepts_nested_attributes_for` declarations.
|
|
12
|
+
#
|
|
13
|
+
# Uses `prepend` so it always wraps `permitted_params` regardless of which
|
|
14
|
+
# controller in the hierarchy defines it — no manual calls needed.
|
|
15
|
+
module ParamsNormalizer
|
|
16
|
+
extend ActiveSupport::Concern
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def normalize_params(permitted)
|
|
21
|
+
hash = permitted.to_h.with_indifferent_access
|
|
22
|
+
hash = resolve_prefixed_ids(hash)
|
|
23
|
+
hash = normalize_nested_attributes(hash)
|
|
24
|
+
ActionController::Parameters.new(hash).permit!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def resolve_prefixed_ids(hash)
|
|
28
|
+
hash.each_with_object({}.with_indifferent_access) do |(key, value), result|
|
|
29
|
+
result[key] = case
|
|
30
|
+
when key.to_s.end_with?('_id') && prefixed_id?(value)
|
|
31
|
+
decode_prefixed_id(value)
|
|
32
|
+
when key.to_s.end_with?('_ids') && value.is_a?(Array)
|
|
33
|
+
value.map { |v| prefixed_id?(v) ? decode_prefixed_id(v) : v }
|
|
34
|
+
when value.is_a?(Hash)
|
|
35
|
+
resolve_prefixed_ids(value)
|
|
36
|
+
when value.is_a?(Array)
|
|
37
|
+
value.map { |v| v.is_a?(Hash) ? resolve_prefixed_ids(v) : v }
|
|
38
|
+
else
|
|
39
|
+
value
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def normalize_nested_attributes(hash, klass = model_class)
|
|
45
|
+
return hash unless klass.respond_to?(:nested_attributes_options)
|
|
46
|
+
|
|
47
|
+
nested_keys = klass.nested_attributes_options.keys.map(&:to_s)
|
|
48
|
+
return hash if nested_keys.empty?
|
|
49
|
+
|
|
50
|
+
hash.each_with_object({}.with_indifferent_access) do |(key, value), result|
|
|
51
|
+
key_str = key.to_s
|
|
52
|
+
if nested_keys.include?(key_str) && !key_str.end_with?('_attributes')
|
|
53
|
+
child_class = klass.reflect_on_association(key_str)&.klass
|
|
54
|
+
result["#{key_str}_attributes"] = normalize_nested_values(value, child_class)
|
|
55
|
+
else
|
|
56
|
+
result[key] = value
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def normalize_nested_values(value, child_class)
|
|
62
|
+
return value unless child_class
|
|
63
|
+
|
|
64
|
+
case value
|
|
65
|
+
when Array
|
|
66
|
+
value.map { |v| v.is_a?(Hash) ? normalize_nested_attributes(v.with_indifferent_access, child_class) : v }
|
|
67
|
+
when Hash
|
|
68
|
+
normalize_nested_attributes(value.with_indifferent_access, child_class)
|
|
69
|
+
else
|
|
70
|
+
value
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def prefixed_id?(value)
|
|
75
|
+
Spree::PrefixedId.prefixed_id?(value)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def decode_prefixed_id(prefixed_id_string)
|
|
79
|
+
Spree::PrefixedId.decode_prefixed_id(prefixed_id_string) || prefixed_id_string
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V3
|
|
4
|
+
# Per-resource scope check for Admin API requests authenticated via API key.
|
|
5
|
+
# JWT-authenticated admin users bypass this and rely on CanCanCan abilities.
|
|
6
|
+
#
|
|
7
|
+
# Controllers declare their scope:
|
|
8
|
+
#
|
|
9
|
+
# class Spree::Api::V3::Admin::OrdersController < ResourceController
|
|
10
|
+
# scoped_resource :orders
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# The before_action maps the action to a `read_*` (index/show) or `write_*`
|
|
14
|
+
# (everything else, including custom member actions) scope and verifies the
|
|
15
|
+
# API key carries it.
|
|
16
|
+
#
|
|
17
|
+
# See docs/plans/5.5-admin-api-key-scopes.md.
|
|
18
|
+
module ScopedAuthorization
|
|
19
|
+
extend ActiveSupport::Concern
|
|
20
|
+
|
|
21
|
+
READ_ACTIONS = %w[index show].freeze
|
|
22
|
+
|
|
23
|
+
class MissingScopedResource < StandardError
|
|
24
|
+
def initialize(controller_class)
|
|
25
|
+
super("#{controller_class} must declare `scoped_resource :name` " \
|
|
26
|
+
'(or `skip_scope_check!` for endpoints exempt from scope checks).')
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class_methods do
|
|
31
|
+
def scoped_resource(name)
|
|
32
|
+
self._scoped_resource = name.to_sym
|
|
33
|
+
self._scope_check_skipped = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Opt out of scope checks (auth, me, tags, direct uploads, etc).
|
|
37
|
+
def skip_scope_check!
|
|
38
|
+
self._scope_check_skipped = true
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
included do
|
|
43
|
+
class_attribute :_scoped_resource, instance_accessor: false
|
|
44
|
+
class_attribute :_scope_check_skipped, instance_accessor: false, default: false
|
|
45
|
+
before_action :authorize_api_key_scope!
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def authorize_api_key_scope!
|
|
51
|
+
return unless current_api_key
|
|
52
|
+
return if self.class._scope_check_skipped
|
|
53
|
+
|
|
54
|
+
resource = scoped_resource_name
|
|
55
|
+
# Fail closed: a controller authenticated by API key MUST declare
|
|
56
|
+
# either `scoped_resource :name`, override `scoped_resource_name`,
|
|
57
|
+
# or `skip_scope_check!`.
|
|
58
|
+
raise MissingScopedResource, self.class unless resource
|
|
59
|
+
|
|
60
|
+
required = "#{action_kind}_#{resource}"
|
|
61
|
+
return if current_api_key.has_scope?(required)
|
|
62
|
+
|
|
63
|
+
render_error(
|
|
64
|
+
code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
|
|
65
|
+
message: "API key lacks scope: #{required}",
|
|
66
|
+
status: :forbidden,
|
|
67
|
+
details: { required_scope: required }
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The resource name used in scope strings (`read_<name>` / `write_<name>`).
|
|
72
|
+
# Defaults to the class-level `scoped_resource :name` declaration.
|
|
73
|
+
# Override in controllers that resolve scope at request time (e.g. the
|
|
74
|
+
# nested-on-many-parents `CustomFieldsController` returns the parent's
|
|
75
|
+
# route segment).
|
|
76
|
+
def scoped_resource_name
|
|
77
|
+
self.class._scoped_resource
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Override in controllers with non-REST custom actions (e.g. dashboard
|
|
81
|
+
# `analytics` should map to a read).
|
|
82
|
+
def action_kind
|
|
83
|
+
READ_ACTIONS.include?(action_name) ? 'read' : 'write'
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|