spree_core 6.0.0.rc2 → 6.0.0.rc4

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 (118) hide show
  1. checksums.yaml +4 -4
  2. data/app/mailers/spree/email_template_mailer.rb +5 -2
  3. data/app/models/concerns/spree/has_external_references.rb +7 -6
  4. data/app/models/concerns/spree/preference_schema/json_schema.rb +8 -1
  5. data/app/models/concerns/spree/purchase/taxation.rb +44 -3
  6. data/app/models/concerns/spree/translatable_resource.rb +49 -0
  7. data/app/models/spree/cart.rb +1 -0
  8. data/app/models/spree/delivery_rate.rb +12 -4
  9. data/app/models/spree/delivery_rate_provider/base.rb +1 -1
  10. data/app/models/spree/delivery_rate_provider/estimate.rb +5 -4
  11. data/app/models/spree/fulfillment.rb +1 -0
  12. data/app/models/spree/gateway/bogus.rb +7 -0
  13. data/app/models/spree/line_item.rb +3 -8
  14. data/app/models/spree/oauth_access_grant.rb +11 -0
  15. data/app/models/spree/oauth_access_token.rb +16 -0
  16. data/app/models/spree/oauth_application.rb +97 -0
  17. data/app/models/spree/oauth_consent_request.rb +23 -0
  18. data/app/models/spree/oauth_protected_resource.rb +21 -0
  19. data/app/models/spree/oauth_redirect.rb +12 -0
  20. data/app/models/spree/option_type.rb +5 -0
  21. data/app/models/spree/option_value.rb +5 -0
  22. data/app/models/spree/order.rb +5 -1
  23. data/app/models/spree/payment.rb +14 -2
  24. data/app/models/spree/payment_capture_event.rb +2 -0
  25. data/app/models/spree/policy.rb +6 -0
  26. data/app/models/spree/price.rb +16 -33
  27. data/app/models/spree/price_rule.rb +3 -16
  28. data/app/models/spree/price_rules/market_rule.rb +0 -6
  29. data/app/models/spree/pricing_provider/base.rb +1 -1
  30. data/app/models/spree/product.rb +1 -1
  31. data/app/models/spree/purchase_order_item.rb +1 -1
  32. data/app/models/spree/return.rb +1 -0
  33. data/app/models/spree/search_provider/database.rb +1 -1
  34. data/app/models/spree/stock/estimator.rb +4 -20
  35. data/app/models/spree/store.rb +8 -0
  36. data/app/models/spree/tax_exemption/item_override.rb +14 -0
  37. data/app/models/spree/tax_exemption.rb +26 -0
  38. data/app/models/spree/tax_exemption_certificate.rb +13 -2
  39. data/app/models/spree/tax_exemption_reason.rb +62 -0
  40. data/app/models/spree/tax_provider/base.rb +24 -4
  41. data/app/models/spree/tax_provider/internal.rb +36 -29
  42. data/app/models/spree/tax_rate.rb +9 -35
  43. data/app/models/spree/translations/batch.rb +14 -4
  44. data/app/services/spree/carts/price_items.rb +2 -8
  45. data/app/services/spree/carts/split_by_seller.rb +6 -1
  46. data/app/services/spree/carts/update.rb +34 -7
  47. data/app/services/spree/email_templates/preview.rb +24 -3
  48. data/app/services/spree/seeds/store_resources.rb +1 -0
  49. data/app/services/spree/seeds/tax_exemption_reasons.rb +28 -0
  50. data/app/services/spree/tax/resolve_exemptions.rb +11 -2
  51. data/app/services/spree/update_check.rb +8 -1
  52. data/app/tools/spree/agent_tools/create_export.rb +116 -0
  53. data/app/tools/spree/agent_tools/create_import.rb +117 -0
  54. data/app/tools/spree/agent_tools/create_media.rb +81 -0
  55. data/app/tools/spree/agent_tools/create_resource.rb +36 -0
  56. data/app/tools/spree/agent_tools/curate_products.rb +90 -0
  57. data/app/tools/spree/agent_tools/delete_resource.rb +28 -0
  58. data/app/tools/spree/agent_tools/describe_import_mapping.rb +73 -0
  59. data/app/tools/spree/agent_tools/describe_reporting.rb +39 -0
  60. data/app/tools/spree/agent_tools/describe_resource.rb +81 -0
  61. data/app/tools/spree/agent_tools/get_import_status.rb +71 -0
  62. data/app/tools/spree/agent_tools/get_resource.rb +97 -0
  63. data/app/tools/spree/agent_tools/import_tool.rb +80 -0
  64. data/app/tools/spree/agent_tools/propose_import_mapping.rb +138 -0
  65. data/app/tools/spree/agent_tools/query_report.rb +124 -0
  66. data/app/tools/spree/agent_tools/resource_write.rb +99 -0
  67. data/app/tools/spree/agent_tools/search_resources.rb +155 -0
  68. data/app/tools/spree/agent_tools/set_stock.rb +52 -0
  69. data/app/tools/spree/agent_tools/update_resource.rb +42 -0
  70. data/app/tools/spree/agent_tools/upload_file.rb +104 -0
  71. data/app/workflows/spree/carts/complete.rb +115 -2
  72. data/app/workflows/spree/catalogs/set_price_list.rb +2 -2
  73. data/app/workflows/spree/orders/cancel.rb +8 -0
  74. data/app/workflows/spree/orders/complete.rb +8 -0
  75. data/app/{services → workflows}/spree/orders/create.rb +9 -9
  76. data/app/workflows/spree/purchase_orders/create.rb +3 -0
  77. data/app/workflows/spree/returns/refund.rb +4 -0
  78. data/app/{services → workflows}/spree/stock_levels/bulk_upsert.rb +49 -7
  79. data/app/workflows/spree/variants/create.rb +7 -4
  80. data/config/locales/en.yml +14 -0
  81. data/db/migrate/20250902143122_fix_policies_store_association.rb +6 -4
  82. data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +11 -2
  83. data/db/migrate/20260904100001_add_applied_tax_exemptions_to_spree_orders.rb +16 -0
  84. data/db/migrate/20260914230000_add_store_to_spree_option_types.rb +12 -2
  85. data/db/migrate/20261005120000_create_spree_oauth_tables.rb +67 -0
  86. data/db/migrate/20261016000001_remove_default_from_spree_tax_rates_included_in_price.rb +9 -0
  87. data/db/migrate/20261016100001_create_spree_tax_exemption_reasons.rb +21 -0
  88. data/db/migrate/20261017100001_add_tax_exemption_reason_to_spree_tax_exemption_certificates.rb +9 -0
  89. data/db/sample_data/orders.rb +1 -1
  90. data/lib/spree/agent_tool.rb +188 -0
  91. data/lib/spree/agent_tools/api_dispatch.rb +204 -0
  92. data/lib/spree/agent_tools/context.rb +191 -0
  93. data/lib/spree/agent_tools/default_catalog.rb +180 -0
  94. data/lib/spree/agent_tools/membership_map.rb +61 -0
  95. data/lib/spree/agent_tools/record_summary.rb +219 -0
  96. data/lib/spree/agent_tools/registry.rb +131 -0
  97. data/lib/spree/agent_tools/resource_map.rb +352 -0
  98. data/lib/spree/agent_tools/workflow_endpoints.rb +88 -0
  99. data/lib/spree/agent_tools/workflow_schema.rb +279 -0
  100. data/lib/spree/agent_tools/workflow_tool.rb +654 -0
  101. data/lib/spree/agent_tools.rb +24 -0
  102. data/lib/spree/core/configuration.rb +1 -1
  103. data/lib/spree/core/controller_helpers/store.rb +3 -13
  104. data/lib/spree/core/dependencies.rb +9 -0
  105. data/lib/spree/core/engine.rb +35 -1
  106. data/lib/spree/core/model_extensions.rb +111 -0
  107. data/lib/spree/core/permissions/default_catalog.rb +6 -1
  108. data/lib/spree/core/pricing/price_resolution.rb +4 -4
  109. data/lib/spree/core/version.rb +1 -1
  110. data/lib/spree/core.rb +27 -2
  111. data/lib/spree/service_module.rb +11 -0
  112. data/lib/spree/testing_support/factories/tax_exemption_certificate_factory.rb +13 -1
  113. data/lib/spree/testing_support/factories/tax_exemption_reason_factory.rb +8 -0
  114. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +38 -0
  115. data/lib/tasks/markets.rake +47 -0
  116. data/lib/tasks/tax_exemption_reasons.rake +48 -0
  117. metadata +67 -7
  118. data/app/models/concerns/spree/vat_price_calculation.rb +0 -99
@@ -0,0 +1,67 @@
1
+ # OAuth 2.1 authorization server tables. Separate from any OAuth Spree
2
+ # carried before 5.4: those served password and client-credentials grants for
3
+ # the legacy API, and nothing migrates forward.
4
+ class CreateSpreeOauthTables < ActiveRecord::Migration[8.1]
5
+ def change
6
+ create_table :spree_oauth_applications do |t|
7
+ t.string :name, null: false
8
+ t.string :uid, null: false
9
+ # Null for a public client, which is every MCP client: they run in a
10
+ # browser or on a vendor's server and cannot hold a secret, which is
11
+ # why PKCE is mandatory rather than optional here.
12
+ t.string :secret
13
+ t.text :redirect_uri
14
+ t.string :scopes, null: false, default: ''
15
+ t.boolean :confidential, null: false, default: false
16
+ t.references :store, null: false, index: true
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :spree_oauth_applications, :uid, unique: true
21
+
22
+ create_table :spree_oauth_access_grants do |t|
23
+ t.references :resource_owner, polymorphic: true, null: false, index: true
24
+ t.references :application, null: false, index: true
25
+ t.string :token, null: false
26
+ t.integer :expires_in, null: false
27
+ t.text :redirect_uri
28
+ t.string :scopes, null: false, default: ''
29
+ t.datetime :revoked_at
30
+ t.string :code_challenge
31
+ t.string :code_challenge_method
32
+ # RFC 8707: what the grant was requested for, carried to the token so
33
+ # the audience can be checked on every call. The column name is
34
+ # Doorkeeper's — its `resource_indicators_supported?` detects the
35
+ # feature by this column's presence.
36
+ t.text :resource
37
+ t.timestamps
38
+ end
39
+
40
+ add_index :spree_oauth_access_grants, :token, unique: true
41
+
42
+ create_table :spree_oauth_access_tokens do |t|
43
+ t.references :resource_owner, polymorphic: true, index: true
44
+ t.references :application, index: true
45
+ # Text rather than string: tokens are hashed, and a hash outgrows 255
46
+ # characters under some configurations.
47
+ t.text :token, null: false
48
+ t.text :refresh_token
49
+ t.integer :expires_in
50
+ t.datetime :revoked_at
51
+ t.string :scopes
52
+ t.string :previous_refresh_token, null: false, default: ''
53
+ t.text :resource
54
+ t.timestamps
55
+ end
56
+
57
+ # Hashed tokens are long, so both lookup indexes cover a prefix on MySQL,
58
+ # where an index over a full-length text column is rejected outright.
59
+ if connection.adapter_name.match?(/mysql/i)
60
+ add_index :spree_oauth_access_tokens, :token, unique: true, length: 255
61
+ add_index :spree_oauth_access_tokens, :refresh_token, unique: true, length: 255
62
+ else
63
+ add_index :spree_oauth_access_tokens, :token, unique: true
64
+ add_index :spree_oauth_access_tokens, :refresh_token, unique: true
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,9 @@
1
+ # Whether tax sits inside a price is the market's setting now, and the column is
2
+ # read once by `spree:markets:backfill_tax_inclusive`. New rates leave it empty,
3
+ # so that step, which every later upgrade runs again, never mistakes one for a
4
+ # rate whose tax was added on top. The column is dropped in 6.1.
5
+ class RemoveDefaultFromSpreeTaxRatesIncludedInPrice < ActiveRecord::Migration[8.1]
6
+ def change
7
+ change_column_default :spree_tax_rates, :included_in_price, from: false, to: nil
8
+ end
9
+ end
@@ -0,0 +1,21 @@
1
+ class CreateSpreeTaxExemptionReasons < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_tax_exemption_reasons do |t|
4
+ t.references :store, null: false, index: false
5
+ t.string :name, null: false
6
+ t.string :code, null: false
7
+ t.boolean :active, null: false, default: true
8
+ if t.respond_to?(:jsonb)
9
+ t.jsonb :metadata
10
+ else
11
+ t.json :metadata
12
+ end
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :spree_tax_exemption_reasons, [:store_id, :name], unique: true,
17
+ name: 'idx_tax_exemption_reasons_on_store_and_name'
18
+ add_index :spree_tax_exemption_reasons, [:store_id, :code], unique: true,
19
+ name: 'idx_tax_exemption_reasons_on_store_and_code'
20
+ end
21
+ end
@@ -0,0 +1,9 @@
1
+ class AddTaxExemptionReasonToSpreeTaxExemptionCertificates < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # Nullable, and reason_code kept: certificates recorded before reasons
4
+ # existed are linked by `spree:upgrade:tax_exemption_reasons`, which reads
5
+ # their reason_code.
6
+ add_reference :spree_tax_exemption_certificates, :tax_exemption_reason
7
+ change_column_null :spree_tax_exemption_certificates, :reason_code, true
8
+ end
9
+ end
@@ -98,7 +98,7 @@ if tax_rate
98
98
  ).first_or_create! do |tax_line|
99
99
  tax_line.amount = 0
100
100
  tax_line.rate = tax_rate.amount
101
- tax_line.included = tax_rate.included_in_price
101
+ tax_line.included = order.tax_inclusive?
102
102
  tax_line.provider_id = 'internal'
103
103
  end
104
104
  end
@@ -0,0 +1,188 @@
1
+ module Spree
2
+ # One entry in the agent-tool registry ({Spree.admin_agent_tools}).
3
+ #
4
+ # A tool is a plain Spree object: a name, a description the model reads, a
5
+ # JSON parameter schema, the permission key that gates it, whether it
6
+ # mutates the store, and an executor. Nothing here knows which client is
7
+ # asking — the MCP server and the dashboard assistant are both adapters
8
+ # over this one registry, so a tool registered once is offered by both.
9
+ #
10
+ # Subclasses implement {#call} and declare their metadata with the class-level
11
+ # DSL:
12
+ #
13
+ # class Spree::AgentTools::SearchResources < Spree::AgentTool
14
+ # tool_name 'search_resources'
15
+ # description 'Search store records.'
16
+ # permission 'read_products'
17
+ # param :resource, description: 'What to search', required: true
18
+ #
19
+ # def call(resource:)
20
+ # # ...
21
+ # end
22
+ # end
23
+ class AgentTool
24
+ # Declarations live in class attributes rather than plain class-level
25
+ # instance variables so a subclass inherits them. Subclassing a tool to
26
+ # tweak it is the documented extension path, and with bare ivars the
27
+ # subclass would come back `mutating? == false` — writing to the store
28
+ # immediately, with no approval card, no permission gate and no argument
29
+ # validation. Inheritance here is a safety property, not a convenience.
30
+ class_attribute :declared_tool_name, instance_accessor: false
31
+ class_attribute :declared_description, instance_accessor: false
32
+ class_attribute :declared_permission, instance_accessor: false
33
+ class_attribute :declared_mutating, instance_accessor: false, default: false
34
+ class_attribute :declared_audience, instance_accessor: false, default: :admin
35
+ class_attribute :declared_params, instance_accessor: false, default: {}.freeze
36
+
37
+ class << self
38
+ # @return [String] the name the model calls this tool by
39
+ def tool_name(value = nil)
40
+ self.declared_tool_name = value if value
41
+ declared_tool_name
42
+ end
43
+
44
+ # @return [String] what the tool does, read by the model when choosing
45
+ def description(value = nil)
46
+ self.declared_description = value if value
47
+ declared_description
48
+ end
49
+
50
+ # RBAC permission key required to use this tool. A user without it never
51
+ # sees the tool in the list handed to the model.
52
+ #
53
+ # @return [String]
54
+ def permission(value = nil)
55
+ self.declared_permission = value if value
56
+ declared_permission
57
+ end
58
+
59
+ # Which surface this tool belongs to: `:admin` for the back office,
60
+ # `:store` for the storefront.
61
+ #
62
+ # Declared on the tool rather than inferred from the registry it was
63
+ # pushed into, so filing one in the wrong place is refused at
64
+ # registration. It matters most for the tools that carry no permission
65
+ # of their own — they are gated per resource, and a blank permission
66
+ # reads as "anyone may see this".
67
+ #
68
+ # @param value [Symbol, nil]
69
+ # @return [Symbol]
70
+ def audience(value = nil)
71
+ self.declared_audience = value.to_sym if value
72
+ declared_audience
73
+ end
74
+
75
+ # Declares that this tool changes store data, so the turn halts for
76
+ # human approval instead of executing.
77
+ #
78
+ # @return [void]
79
+ def mutating!
80
+ self.declared_mutating = true
81
+ end
82
+
83
+ # @return [Boolean]
84
+ def mutating?
85
+ declared_mutating == true
86
+ end
87
+
88
+ # @param name [Symbol]
89
+ # @param type [Symbol] JSON schema type
90
+ # @param description [String]
91
+ # @param required [Boolean]
92
+ # @param items [Symbol] what an array holds — a JSON Schema array without
93
+ # it is ambiguous, and a client that validates arguments against the
94
+ # schema rejects the call before the tool ever runs
95
+ # @return [void]
96
+ def param(name, type: :string, description: nil, required: false, items: nil)
97
+ # Reassigned rather than mutated, so declaring a param on a subclass
98
+ # does not reach back and change the parent's list.
99
+ self.declared_params = declared_params.merge(
100
+ name => { type: type, description: description, required: required, items: items }
101
+ ).freeze
102
+ end
103
+
104
+ # @return [Hash{Symbol => Hash}]
105
+ def params
106
+ declared_params
107
+ end
108
+ end
109
+
110
+ # @return [Spree::AgentTools::Context] who is asking, and where
111
+ attr_reader :context
112
+
113
+ # @param context [Spree::AgentTools::Context]
114
+ def initialize(context)
115
+ @context = context
116
+ end
117
+
118
+ delegate :tool_name, :description, :permission, :mutating?, :params, to: :class
119
+
120
+ # Runs the tool. Subclasses implement this with keyword arguments matching
121
+ # their declared params.
122
+ #
123
+ # @return [Hash] the result handed back to the model
124
+ def call(**)
125
+ raise NotImplementedError, 'Agent tools must implement #call'
126
+ end
127
+
128
+ # One line saying what approving this call would do. It is the entire
129
+ # approval card — the merchant decides on this sentence and nothing else,
130
+ # so every mutating tool should override it with something that reads
131
+ # like an instruction ("Set Blue Shirt to draft"), naming the record
132
+ # rather than its id.
133
+ #
134
+ # The fallback spells the arguments out rather than dumping JSON, so a
135
+ # tool that forgets to override is merely terse instead of unreadable.
136
+ #
137
+ # @param arguments [Hash]
138
+ # @return [String]
139
+ def summary(arguments)
140
+ described = arguments.map { |key, value| "#{key.to_s.humanize.downcase} #{value}" }.join(', ')
141
+
142
+ [tool_name.humanize, described.presence].compact.join(': ')
143
+ end
144
+
145
+ # Whether the asking user may use this tool at all.
146
+ #
147
+ # @return [Boolean]
148
+ def permitted?
149
+ context.permitted?(permission)
150
+ end
151
+
152
+ # A resource the map does not hold, answered with the ones it does.
153
+ #
154
+ # Every tool that takes a resource key refuses the same way: naming what is
155
+ # available is what lets the model correct itself in one step instead of
156
+ # guessing again, and a caller only ever sees resources its own credential
157
+ # may read.
158
+ #
159
+ # @param key [String]
160
+ # @return [Hash]
161
+ def unknown_resource(key)
162
+ {
163
+ error: "Unknown resource #{key.to_s.inspect}. Call describe_resource for the list.",
164
+ available: Spree::AgentTools::ResourceMap.available_for(context).map(&:key)
165
+ }
166
+ end
167
+
168
+ protected
169
+
170
+ # Refuses unless this admin may take `action` on `record`.
171
+ #
172
+ # Holding `write_products` says an admin may edit products; it does not
173
+ # say they may edit *this* one. A host app can narrow that with a
174
+ # record-level rule, and the Admin API honours it — so any tool that
175
+ # changes a record must ask here first. Returns an error the model reads
176
+ # rather than raising, so the assistant explains itself instead of the
177
+ # turn dying.
178
+ #
179
+ # @param action [Symbol]
180
+ # @param record [Object]
181
+ # @return [Hash, nil] an error result to return, or nil when allowed
182
+ def unauthorized(action, record)
183
+ return if context.can?(action, record)
184
+
185
+ { error: "You do not have permission to #{action} this #{record.class.name.demodulize.underscore.humanize.downcase}." }
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,204 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Runs an Admin API operation for a tool, in this process.
4
+ #
5
+ # A tool names an operation; this is what reaches it. The request goes
6
+ # through the whole stack — routing, authentication, the controller, the
7
+ # serializer, the error format — so a tool reaches data the way any other
8
+ # client does, and a guard added to the API protects agents without anyone
9
+ # copying it here.
10
+ #
11
+ # Deliberately not HTTP to itself. A socket back to the same application
12
+ # holds one web thread waiting on another, so a few concurrent tool calls
13
+ # on a small pool deadlock the process, and it buys TLS, DNS and timeouts
14
+ # for nothing.
15
+ class ApiDispatch
16
+ # What a dispatched call answers with.
17
+ Response = Struct.new(:status, :body, :headers, :raw, keyword_init: true) do
18
+ # What the endpoint actually sent, for an operation whose answer is a
19
+ # file rather than a record.
20
+ #
21
+ # @return [String, nil]
22
+ def content
23
+ raw
24
+ end
25
+
26
+ # @return [String, nil]
27
+ def content_type
28
+ headers.to_h.find { |name, _| name.to_s.casecmp('content-type').zero? }&.last
29
+ end
30
+
31
+ # @return [Boolean]
32
+ def success?
33
+ status.between?(200, 299)
34
+ end
35
+
36
+ # The error the API itself rendered, in its own words. A tool repeats
37
+ # this rather than inventing a message, so a refusal reads the same
38
+ # however it was reached.
39
+ #
40
+ # @return [String, nil]
41
+ def error_message
42
+ return if success?
43
+
44
+ error = body.is_a?(Hash) ? body['error'] : nil
45
+ return error if error.is_a?(String)
46
+ return error['message'] if error.is_a?(Hash) && error['message'].present?
47
+
48
+ "The request failed (#{status})."
49
+ end
50
+
51
+ # @return [Hash, Array, nil]
52
+ def data
53
+ body.is_a?(Hash) && body.key?('data') ? body['data'] : body
54
+ end
55
+
56
+ # @return [Hash, nil] pagination, when the operation is a listing
57
+ def meta
58
+ body['meta'] if body.is_a?(Hash)
59
+ end
60
+ end
61
+
62
+ # Only what the Admin API reads. Anything else a client sent is the MCP
63
+ # request's business, not the operation's.
64
+ FORWARDED_HEADERS = %w[HTTP_AUTHORIZATION HTTP_X_SPREE_API_KEY HTTP_HOST].freeze
65
+
66
+ # @param context [Spree::AgentTools::Context]
67
+ def initialize(context)
68
+ @context = context
69
+ end
70
+
71
+ # @param method [Symbol] :get, :post, :patch, :delete
72
+ # @param path [String] the operation's full path, already interpolated —
73
+ # the resource map's paths are absolute (`/api/v3/admin/products`)
74
+ # @param params [Hash] query parameters for a read
75
+ # @param body [Hash, nil] the request body for a write. A value that
76
+ # responds to `read` makes the request multipart, which is how the
77
+ # upload endpoint takes bytes.
78
+ # @return [Response]
79
+ def call(method:, path:, params: {}, body: nil)
80
+ env = build_env(method, path, params, body)
81
+
82
+ # `Spree::Current` is an `ActiveSupport::CurrentAttributes`, which does
83
+ # NOT reset for a request made inside another request — the executor
84
+ # is re-entrant. Measured on this branch before this class existed: an
85
+ # inner call left the outer store in place and overwrote its currency.
86
+ #
87
+ # So the outer state is copied, cleared so the inner request resolves
88
+ # its own from its own headers, and put back whatever happened. The
89
+ # restore has to sit in this method's own `ensure`: wrapped in a
90
+ # helper that yields, it ran before the executor finished unwinding
91
+ # and the outer values were lost again.
92
+ saved = Spree::Current.attributes.to_a
93
+ # `RequestStore` is per-request too, and the inner request's middleware
94
+ # clears it on the way out — leaving `RequestStore.store` nil, which
95
+ # the store scope guard indexes into on the outer request's next
96
+ # query. Saved unconditionally, including when it is already empty:
97
+ # restoring an empty hash is what keeps it a hash.
98
+ saved_request_store = defined?(RequestStore) ? RequestStore.store.to_h.dup : nil
99
+
100
+ begin
101
+ status, headers, rack_body = Rails.application.call(env)
102
+ text = read(rack_body)
103
+ Response.new(status: status, headers: headers, body: parse(text), raw: text)
104
+ ensure
105
+ # The executor flushes `ActiveSupport::ExecutionContext` as the
106
+ # inner request unwinds, and that is where `CurrentAttributes` keeps
107
+ # its per-thread instances — so the outer request's next read of
108
+ # `Spree::Current` found no registry and raised inside Rails. A
109
+ # frame has to be pushed back BEFORE the values are written, or the
110
+ # writes land in a frame that is then replaced and read as nil.
111
+ ActiveSupport::ExecutionContext.push if defined?(ActiveSupport::ExecutionContext)
112
+ saved.each { |name, value| Spree::Current.public_send(:"#{name}=", value) }
113
+ RequestStore.store = saved_request_store unless saved_request_store.nil?
114
+ end
115
+ end
116
+
117
+ private
118
+
119
+ def build_env(method, path, params, body)
120
+ query = params.present? ? Rack::Utils.build_nested_query(stringify(params)) : nil
121
+
122
+ env = Rack::MockRequest.env_for(
123
+ path,
124
+ method: method.to_s.upcase,
125
+ params: query,
126
+ **payload_env(body)
127
+ ).compact
128
+
129
+ forwarded.each { |name, value| env[name] = value }
130
+ env['HTTP_ACCEPT'] = 'application/json'
131
+ env
132
+ end
133
+
134
+ # JSON unless the body carries an IO, in which case the request is
135
+ # multipart — the shape `POST /files` reads bytes from, and the only
136
+ # way to hand it a file without a second request from outside.
137
+ def payload_env(body)
138
+ return {} if body.blank?
139
+
140
+ file = body.find { |_name, value| value.respond_to?(:read) }
141
+ return { input: JSON.generate(body), 'CONTENT_TYPE' => 'application/json' } if file.nil?
142
+
143
+ boundary = "SpreeAgentTools#{SecureRandom.hex(16)}"
144
+ {
145
+ input: multipart_body(body, file, boundary),
146
+ 'CONTENT_TYPE' => "multipart/form-data; boundary=#{boundary}"
147
+ }
148
+ end
149
+
150
+ # Written out rather than taken from `Rack::Test`, which is a test
151
+ # dependency of neither gem and would be missing from a real install.
152
+ def multipart_body(body, file, boundary)
153
+ name, io = file
154
+ filename = body['filename'].presence || 'file'
155
+ content_type = body['content_type'].presence || 'application/octet-stream'
156
+
157
+ parts = body.except(name).filter_map do |field, value|
158
+ next if value.nil?
159
+
160
+ "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{field}\"\r\n\r\n#{value}\r\n"
161
+ end
162
+
163
+ parts << "--#{boundary}\r\n" \
164
+ "Content-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\"\r\n" \
165
+ "Content-Type: #{content_type}\r\n\r\n"
166
+
167
+ # Binary, because the bytes are: joining them into a UTF-8 string
168
+ # raises on the first image that is not valid UTF-8.
169
+ parts.join.dup.force_encoding(Encoding::BINARY) +
170
+ io.read.dup.force_encoding(Encoding::BINARY) +
171
+ "\r\n--#{boundary}--\r\n".dup.force_encoding(Encoding::BINARY)
172
+ end
173
+
174
+ # The credential the caller authenticated with, repeated verbatim. Never
175
+ # an internal header that skips authentication: that would rebuild the
176
+ # second security boundary this class exists to remove.
177
+ def forwarded
178
+ @context.request_headers.slice(*FORWARDED_HEADERS)
179
+ end
180
+
181
+ def read(rack_body)
182
+ text = +''
183
+ rack_body.each { |chunk| text << chunk }
184
+ rack_body.close if rack_body.respond_to?(:close)
185
+ text
186
+ end
187
+
188
+ # A download answers with the file, not a record, so a body that is not
189
+ # JSON is not an error — it is kept as it came in {Response#content}.
190
+ def parse(text)
191
+ return if text.blank?
192
+
193
+ JSON.parse(text)
194
+ rescue JSON::ParserError
195
+ nil
196
+ end
197
+
198
+ # Rack wants strings; a tool passes whatever the model sent.
199
+ def stringify(params)
200
+ params.to_h.deep_transform_keys(&:to_s)
201
+ end
202
+ end
203
+ end
204
+ end