spree_core 6.0.2 → 6.0.3

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/addresses/geocode_address_job.rb +6 -3
  3. data/app/jobs/spree/variants/remove_line_item_job.rb +5 -0
  4. data/app/models/concerns/spree/preference_schema.rb +5 -0
  5. data/app/models/concerns/spree/typed_associations.rb +53 -20
  6. data/app/models/spree/address.rb +2 -2
  7. data/app/models/spree/collection.rb +7 -2
  8. data/app/models/spree/order.rb +1 -1
  9. data/app/models/spree/product.rb +15 -5
  10. data/app/models/spree/promotion.rb +1 -2
  11. data/app/services/spree/newsletter/subscribe.rb +1 -1
  12. data/app/services/spree/orders/create_from_cart.rb +6 -2
  13. data/app/tools/spree/agent_tools/create_export.rb +3 -1
  14. data/app/tools/spree/agent_tools/create_import.rb +1 -0
  15. data/app/tools/spree/agent_tools/create_media.rb +2 -1
  16. data/app/tools/spree/agent_tools/create_resource.rb +12 -4
  17. data/app/tools/spree/agent_tools/delete_resource.rb +12 -4
  18. data/app/tools/spree/agent_tools/describe_resource.rb +69 -6
  19. data/app/tools/spree/agent_tools/get_resource.rb +7 -4
  20. data/app/tools/spree/agent_tools/resource_write.rb +15 -2
  21. data/app/tools/spree/agent_tools/search_resources.rb +10 -11
  22. data/app/tools/spree/agent_tools/update_resource.rb +6 -6
  23. data/app/tools/spree/agent_tools/upload_file.rb +1 -0
  24. data/app/workflows/spree/carts/complete.rb +4 -1
  25. data/app/workflows/spree/carts/merge.rb +25 -4
  26. data/app/workflows/spree/orders/complete.rb +39 -14
  27. data/app/workflows/spree/products/activate.rb +7 -2
  28. data/app/workflows/spree/products/archive.rb +7 -2
  29. data/app/workflows/spree/products/draft.rb +7 -2
  30. data/app/workflows/spree/products/status_transition.rb +24 -0
  31. data/config/locales/en.yml +3 -0
  32. data/lib/generators/spree/api_resource/api_resource_generator.rb +35 -3
  33. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +9 -4
  34. data/lib/generators/spree/api_resource/templates/admin_integration_spec.rb.tt +74 -0
  35. data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +11 -6
  36. data/lib/spree/agent_tool.rb +105 -1
  37. data/lib/spree/agent_tools/default_catalog.rb +16 -21
  38. data/lib/spree/agent_tools/legacy_tools.rb +212 -0
  39. data/lib/spree/agent_tools/operation_catalog.rb +206 -0
  40. data/lib/spree/agent_tools/operation_tool.rb +189 -0
  41. data/lib/spree/agent_tools/registry.rb +65 -31
  42. data/lib/spree/agent_tools/resource_map.rb +128 -5
  43. data/lib/spree/agent_tools.rb +3 -3
  44. data/lib/spree/core/dependencies_helper.rb +96 -40
  45. data/lib/spree/core/engine.rb +2 -1
  46. data/lib/spree/core/version.rb +1 -1
  47. data/lib/spree/core.rb +19 -2
  48. data/lib/spree/testing_support/extension_rake.rb +12 -0
  49. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +34 -0
  50. data/lib/tasks/dependencies.rake +2 -2
  51. metadata +23 -7
  52. data/lib/spree/agent_tools/workflow_endpoints.rb +0 -88
  53. data/lib/spree/agent_tools/workflow_schema.rb +0 -279
  54. data/lib/spree/agent_tools/workflow_tool.rb +0 -654
@@ -0,0 +1,189 @@
1
+ module Spree
2
+ module AgentTools
3
+ # One agent tool per Admin API operation in {OperationCatalog}.
4
+ #
5
+ # Everything about the tool is the operation's: its name, its prose, its
6
+ # arguments and the request it sends. The arguments are the path segments
7
+ # and the request body under the names the document gives them, so a call
8
+ # is a request with no translation step between what the model was shown
9
+ # and what the endpoint receives. The endpoint runs its own routing,
10
+ # authentication, scope check, record-level ability and workflow.
11
+ #
12
+ # Generated classes are cached per entry, so a catalog rebuilt after a
13
+ # code reload produces new tools rather than serving stale ones.
14
+ #
15
+ # See docs/plans/6.0-agent-tools-from-openapi.md.
16
+ class OperationTool < Spree::AgentTool
17
+ class_attribute :operation, instance_accessor: false
18
+
19
+ class << self
20
+ # @param entry [OperationCatalog::Entry]
21
+ # @return [Class<OperationTool>]
22
+ def for(entry)
23
+ # By identity: an entry is rebuilt, as a new object, whenever the
24
+ # catalog is, and hashing one walks its whole request schema.
25
+ cache[entry] ||= Class.new(self) do
26
+ self.operation = entry
27
+
28
+ tool_name entry.name
29
+ description entry.description
30
+ permission entry.permission
31
+ mutating! if entry.mutating
32
+ end
33
+ end
34
+
35
+ # @return [Hash] JSON Schema for the arguments, read by the adapters
36
+ def input_schema
37
+ @input_schema ||= operation.input_schema
38
+ end
39
+
40
+ # @return [void]
41
+ def clear_cache!
42
+ @cache = {}
43
+ end
44
+
45
+ private
46
+
47
+ def cache
48
+ @cache ||= {}.compare_by_identity
49
+ end
50
+ end
51
+
52
+ delegate :operation, to: :class
53
+
54
+ # @return [Hash] the result handed back to the model
55
+ def call(**arguments)
56
+ arguments, notes = map_legacy_arguments(arguments.transform_keys(&:to_s))
57
+
58
+ segments = operation.path_params.index_with { |segment| arguments[segment] }
59
+ derive_parents(segments)
60
+ path = operation.path_for(segments)
61
+ # Only what the path needs is checked here. What the body requires is
62
+ # the endpoint's to refuse, in its own words.
63
+ if path.nil?
64
+ unfilled = segments.select { |_segment, value| value.blank? }.keys
65
+ return { error: "#{tool_name} needs #{unfilled.to_sentence}.", deprecations: notes.presence }.compact
66
+ end
67
+
68
+ values, refusal = body_for(arguments)
69
+ return refusal.merge(deprecations: notes.presence).compact if refusal
70
+
71
+ response = dispatch(path, values)
72
+ return { error: response.error_message, deprecations: notes.presence }.compact unless response.success?
73
+
74
+ # A read opted in to the catalog answers with what it read, filtered
75
+ # like every other record that leaves for a model.
76
+ return { summary: operation.summary, data: sanitized(response.data) } if operation.read?
77
+
78
+ record = payload_summary(response.data)
79
+ named = record&.dig(:title).presence || record&.dig(:id).presence || record&.dig('id').presence
80
+ { summary: named ? "#{operation.summary} #{named}" : summary(arguments), record: record,
81
+ deprecations: notes.presence }.compact
82
+ end
83
+
84
+ # The line a client shows before the call runs: the operation's own
85
+ # summary and the records it names.
86
+ #
87
+ # @param arguments [Hash]
88
+ # @return [String]
89
+ def summary(arguments)
90
+ arguments, = map_legacy_arguments(arguments.transform_keys(&:to_s))
91
+ named = operation.path_params.filter_map { |segment| arguments[segment] }
92
+ [operation.summary, named.reverse.join(' ').presence].compact.join(' ')
93
+ end
94
+
95
+ private
96
+
97
+ # 6.0.0 names (`order`, `reason`) mapped to the operation's (`id`,
98
+ # `cancel_reason_id`), with a note per name so the model learns the new
99
+ # one. A name the endpoint never read is dropped and said to be.
100
+ def map_legacy_arguments(arguments)
101
+ renames = operation.legacy_parameters
102
+ return [arguments, []] if renames.blank?
103
+
104
+ notes = []
105
+ mapped = arguments.each_with_object({}) do |(name, value), result|
106
+ next result[name] = value unless renames.key?(name)
107
+
108
+ target = renames[name]
109
+ if target == :merge && value.is_a?(Hash)
110
+ notes << "#{name} is deprecated: send its fields as arguments of their own."
111
+ result.merge!(value.transform_keys(&:to_s)) { |_key, given, _merged| given }
112
+ elsif target.nil? || target == :merge
113
+ notes << "#{name} is not something #{tool_name} sets, and was ignored."
114
+ else
115
+ notes << "#{name} is deprecated: use #{target}."
116
+ result[target] = value unless result.key?(target)
117
+ end
118
+ end
119
+
120
+ [mapped, notes]
121
+ end
122
+
123
+ # A parent segment the caller left out, read off a record the call
124
+ # does name — a payment's order, a fulfillment's order — through the
125
+ # resource map, under the caller's store. Only a record the caller may
126
+ # read is consulted; anything else stays unfilled and is asked for.
127
+ def derive_parents(segments)
128
+ missing = operation.parent_keys.keys.select { |segment| segments[segment].blank? }
129
+ return if missing.empty?
130
+
131
+ records = segments.compact_blank.to_a.reverse.filter_map { |segment, id| record_for(segment, id) }
132
+ missing.reverse_each do |segment|
133
+ parent = records.lazy.filter_map { |record| record.try(segment.delete_suffix('_id')) }.first
134
+ next if parent.nil?
135
+
136
+ segments[segment] = parent.try(:prefixed_id)
137
+ records << parent
138
+ end
139
+ end
140
+
141
+ def record_for(segment, id)
142
+ key = segment == 'id' ? operation.resource_key : operation.segment_keys[segment]
143
+ entry = key && ResourceMap.find(key)
144
+ return if entry.nil? || !context.permitted?(entry.permission)
145
+
146
+ entry.scope_for(context).find_by_prefix_id(id)
147
+ rescue StandardError
148
+ nil
149
+ end
150
+
151
+ def dispatch(path, values)
152
+ dispatcher = ApiDispatch.new(context)
153
+ return dispatcher.call(method: :get, path: path, params: values || {}) if operation.read?
154
+
155
+ dispatcher.call(method: operation.verb.to_sym, path: path, body: values)
156
+ end
157
+
158
+ # What travels as the body, or a read's query: every argument that is
159
+ # not a path segment, checked against the document so a guessed field is
160
+ # named back rather than dropped behind a success.
161
+ def body_for(arguments)
162
+ body = arguments.except(*operation.path_params)
163
+ return [nil, nil] if body.empty?
164
+
165
+ accepted = operation.body_properties.keys
166
+ unknown = body.keys - accepted
167
+ return [body, nil] if unknown.empty?
168
+
169
+ [nil, { error: "#{tool_name} does not accept #{unknown.to_sentence}.",
170
+ accepted_arguments: (operation.path_params + accepted).sort }]
171
+ end
172
+
173
+ # The record the endpoint returned, compacted and filtered the way a
174
+ # record read from the map is.
175
+ def payload_summary(data)
176
+ return unless data.is_a?(Hash)
177
+
178
+ entry = operation.resource_key && ResourceMap.find(operation.resource_key)
179
+ return sanitized(data).slice('id').presence if entry.nil?
180
+
181
+ RecordSummary.from_payload(entry: entry, payload: data)
182
+ end
183
+
184
+ def sanitized(data)
185
+ RecordSummary.sanitize(data, operation.resource_key)
186
+ end
187
+ end
188
+ end
189
+ end
@@ -18,10 +18,9 @@ module Spree
18
18
  # of their own — they are gated per resource instead — so the registry
19
19
  # cannot be the only thing keeping them apart.
20
20
  #
21
- # Writes are not registered as classes. A back-office write already is a
22
- # workflow, so the registry exposes workflows by their `Spree::Dependencies`
23
- # key and derives each tool from the workflow's own `perform` signature —
24
- # see {#expose_workflows}.
21
+ # Writes are not registered as classes. Every Admin API action an admin
22
+ # key may call is already a tool, built from its OpenAPI operation by
23
+ # {OperationCatalog} — see docs/plans/6.0-agent-tools-from-openapi.md.
25
24
  class Registry
26
25
  include Enumerable
27
26
 
@@ -61,38 +60,54 @@ module Spree
61
60
  to_a.each(&)
62
61
  end
63
62
 
64
- # Exposes workflows as write tools, keyed by `Spree::Dependencies` key so
65
- # the tool follows whatever class the host app has registered:
66
- #
67
- # Spree.admin_agent_tools.expose_workflows(
68
- # order_cancel_workflow: 'write_orders',
69
- # product_activate_workflow: 'write_products'
70
- # )
71
- #
72
- # A value may be the permission key on its own, or a hash carrying it
73
- # alongside `except:` (parameters to hide, such as a deprecated one) and
74
- # `summary:` (a callable overriding the generated confirmation line).
63
+ # @deprecated Admin API operations are agent tools on their own, built
64
+ # from the OpenAPI document, so there is nothing to expose. A
65
+ # workflow behind a documented endpoint is that endpoint's tool; one
66
+ # without is not offered. Recorded and otherwise ignored, and removed
67
+ # in 7.0.
75
68
  #
76
69
  # @param exposures [Hash{Symbol => String, Hash}]
77
70
  # @return [self]
78
71
  def expose_workflows(**exposures)
79
- exposures.each do |dependency_key, options|
80
- options = { permission: options } if options.is_a?(String) || options.is_a?(Symbol)
81
- @exposed_workflows[dependency_key.to_sym] = {
82
- permission: options.fetch(:permission).to_s,
83
- except: Array(options[:except]).map(&:to_sym),
84
- summary: options[:summary]
85
- }
86
- end
72
+ Spree::Deprecation.warn(
73
+ 'Spree.admin_agent_tools.expose_workflows is deprecated and has no effect: every documented ' \
74
+ 'Admin API action is an agent tool, built from its OpenAPI operation. A workflow reaches agents ' \
75
+ 'through an admin endpoint whose rswag spec is generated into the openapi/admin.yaml your gem ships. ' \
76
+ 'See docs/plans/6.0-agent-tools-from-openapi.md.'
77
+ )
78
+
79
+ @exposed_workflows.merge!(exposures.transform_keys(&:to_sym))
87
80
  self
88
81
  end
89
82
 
90
- # @return [Hash{Symbol => Hash}] every exposed workflow, by dependency key
83
+ # @deprecated see {#expose_workflows}
84
+ # @return [Hash{Symbol => Hash}] what was passed to {#expose_workflows}
91
85
  attr_reader :exposed_workflows
92
86
 
93
87
  # @return [Array<Class<Spree::AgentTool>>]
94
88
  def to_a
95
- registered_classes + workflow_tool_classes
89
+ registered = registered_classes
90
+ registered + operation_tool_classes(registered.map(&:tool_name))
91
+ end
92
+
93
+ # A tool this caller may use, by name — including a 6.0.0 name that is
94
+ # no longer listed but still answers through 6.0.x, so a client that
95
+ # learned it keeps working.
96
+ #
97
+ # @param name [String]
98
+ # @param context [Spree::AgentTools::Context]
99
+ # @return [Spree::AgentTool, nil]
100
+ def find_for(name, context)
101
+ name = name.to_s
102
+ tool_class = registered_classes.find { |klass| klass.tool_name == name }
103
+ if tool_class.nil? && operations?
104
+ entry = Spree::AgentTools::OperationCatalog.find(name)
105
+ tool_class = entry && Spree::AgentTools::OperationTool.for(entry)
106
+ end
107
+ return if tool_class.nil? || (tool_class.try(:audience) && tool_class.audience != audience)
108
+
109
+ tool = tool_class.new(context)
110
+ tool if tool.permitted?
96
111
  end
97
112
 
98
113
  # Tools this caller may actually use, instantiated for their context.
@@ -118,14 +133,33 @@ module Spree
118
133
  end
119
134
  end
120
135
 
121
- # One generated tool class per exposed workflow whose dependency key
122
- # still resolves. A key an extension removed simply stops being offered
123
- # rather than breaking the whole catalog.
124
- def workflow_tool_classes
125
- @exposed_workflows.filter_map do |dependency_key, options|
126
- Spree::AgentTools::WorkflowTool.for(dependency_key, **options)
136
+ # One tool per operation the catalog lists. Operations are back-office
137
+ # actions, so only the admin surface offers them.
138
+ #
139
+ # A registered tool keeps its name: an operation claiming the same one
140
+ # would make an MCP server refuse the whole list. The contract spec
141
+ # fails on such a clash; at runtime it is logged, once per name.
142
+ def operation_tool_classes(taken)
143
+ return [] unless operations?
144
+
145
+ Spree::AgentTools::OperationCatalog.all.filter_map do |entry|
146
+ next Spree::AgentTools::OperationTool.for(entry) unless taken.include?(entry.name)
147
+
148
+ warn_shadowed(entry.name)
149
+ nil
127
150
  end
128
151
  end
152
+
153
+ def warn_shadowed(name)
154
+ @shadowed ||= Set.new
155
+ return unless @shadowed.add?(name)
156
+
157
+ Rails.logger.warn("[Spree] agent tool #{name} is registered by hand; the operation of that name is not offered")
158
+ end
159
+
160
+ def operations?
161
+ audience == :admin
162
+ end
129
163
  end
130
164
  end
131
165
  end
@@ -16,6 +16,10 @@ module Spree
16
16
  # than quietly leaking another store's records.
17
17
  UnscopedResourceError = Class.new(StandardError)
18
18
 
19
+ # The parent segment of a nested path: `/promotions/:promotion_id/promotion_rules`
20
+ # names `promotions`.
21
+ PARENT_SEGMENT = %r{/(?<parent>[a-z_]+)/:[a-z_]+_id(?:/|\z)}
22
+
19
23
  Entry = Struct.new(:key, :model_name, :permission, :serializer_name,
20
24
  :dashboard_path, :scope_name, :write_permission,
21
25
  :writable_attributes, :create_workflow_key, :update_workflow_key,
@@ -29,9 +33,38 @@ module Spree
29
33
  Array(api_paths).to_h[action.to_sym]
30
34
  end
31
35
 
32
- # @return [Boolean] whether a tool can dispatch to this resource
36
+ # Whether a tool can dispatch to this resource's collection on its own.
37
+ #
38
+ # A resource reached only under a parent is not: the tool has no
39
+ # parent id to put in the path, and dispatching the template as it
40
+ # stands answered "Promotion not found" for a search of promotion
41
+ # rules, which a model repeats as a fact about the store.
42
+ #
43
+ # @return [Boolean]
33
44
  def dispatchable?
34
- api_path(:index).present?
45
+ api_path(:index).present? && !nested?(:index)
46
+ end
47
+
48
+ # Whether the path a tool would dispatch `action` to runs under a
49
+ # parent. Asked per action, because a resource can be listed at the
50
+ # top level and written under its parent (`/variants`,
51
+ # `/products/:product_id/variants`).
52
+ #
53
+ # @param action [Symbol]
54
+ # @return [Boolean]
55
+ def nested?(action = :index)
56
+ parent_key(action).present?
57
+ end
58
+
59
+ # The resource key of the parent `action`'s path runs under, read
60
+ # from the route rather than guessed from the model, so a renamed
61
+ # nested route (`/orders/:order_id/items`) still names `orders`. The
62
+ # innermost parent, for a path nested twice.
63
+ #
64
+ # @param action [Symbol]
65
+ # @return [String, nil]
66
+ def parent_key(action = :index)
67
+ api_path(action).to_s.scan(PARENT_SEGMENT).flatten.last
35
68
  end
36
69
 
37
70
  # Whether a search may add DISTINCT.
@@ -67,6 +100,42 @@ module Spree
67
100
  Array(writable_attributes).map(&:to_s).sort
68
101
  end
69
102
 
103
+ # The record a nested resource is reached through — `order_id` for an
104
+ # order's items, discounts and fees — or nil for a top-level one.
105
+ #
106
+ # @param action [Symbol]
107
+ # @return [String, nil]
108
+ def parent_param(action)
109
+ Array(api_path(action)&.scan(/:([a-z_]+)/)).flatten.reject { |name| name == 'id' }.first
110
+ end
111
+
112
+ # The operation's path with its segments filled, or nil when a nested
113
+ # resource was not given the record it lives under.
114
+ #
115
+ # Each value is percent-encoded so it stays one segment: a `?` or `/`
116
+ # in an id would otherwise send the call to a different route than
117
+ # the one the tool checked and the approver was shown. A path with a
118
+ # segment left unfilled — a resource nested two levels deep — is nil
119
+ # too, rather than dispatched with a placeholder in it.
120
+ #
121
+ # @param action [Symbol]
122
+ # @param id [String, nil]
123
+ # @param parent_id [String, nil]
124
+ # @return [String, nil]
125
+ def path_for(action, id: nil, parent_id: nil)
126
+ path = api_path(action)
127
+ return if path.blank?
128
+
129
+ path = path.sub(':id', ERB::Util.url_encode(id.to_s)) if id.present?
130
+ parent = parent_param(action)
131
+ if parent
132
+ return if parent_id.blank?
133
+
134
+ path = path.sub(":#{parent}", ERB::Util.url_encode(parent_id.to_s))
135
+ end
136
+ path unless path.match?(/:[a-z_]+/)
137
+ end
138
+
70
139
  # The associations the show operation will include when asked, read
71
140
  # from the serializer that renders it.
72
141
  #
@@ -92,14 +161,68 @@ module Spree
92
161
  end
93
162
 
94
163
  def expand_names_in(klass)
95
- file = Object.const_source_location(klass.name)&.first
96
- return [] if file.nil? || !File.exist?(file)
164
+ file = serializer_file(klass)
165
+ return [] if file.nil?
97
166
 
98
- File.read(file).scan(/expand\?\('([a-z_]+)'\)/).flatten
167
+ # Comment lines are skipped: the base serializer explains `expand?`
168
+ # with an example, which put `variants` on every resource's list.
169
+ code = File.readlines(file).reject { |line| line.lstrip.start_with?('#') }.join
170
+ code.scan(/expand\?\('([a-z_]+)'\)/).flatten
99
171
  rescue StandardError
100
172
  []
101
173
  end
102
174
 
175
+ # The file the autoloader maps the class to, under whichever engine
176
+ # holds it. `const_source_location` is the fallback: it names the file
177
+ # that first touched the constant, which for a serializer another one
178
+ # inherits from was the subclass's file.
179
+ def serializer_file(klass)
180
+ relative = "#{klass.name.underscore}.rb"
181
+ found = Rails.autoloaders.main.dirs.lazy.map { |dir| File.join(dir, relative) }.find { |path| File.exist?(path) }
182
+ found || Object.const_source_location(klass.name)&.first.then { |path| path if path && File.exist?(path) }
183
+ end
184
+
185
+ # The STI family this resource is, when its rows are selected by a
186
+ # `type` the model's own registry resolves — payment methods, seller
187
+ # requirements, promotion rules. Nil for an ordinary resource.
188
+ #
189
+ # @return [Class, nil]
190
+ def typed_family
191
+ typed_family_class(model_class)
192
+ end
193
+
194
+ # The STI family behind each list-valued attribute whose rows carry a
195
+ # `type` — a promotion's `rules` and `actions`, a price list's
196
+ # `rules`. The attribute is the short name the write accepts; the
197
+ # association behind it carries the model's prefix
198
+ # (`promotion_rules`), which is how the two are matched.
199
+ #
200
+ # @return [Hash{String => Class}] attribute => family class
201
+ def nested_typed_families
202
+ nested_collection_shapes.each_with_object({}) do |(name, fields), families|
203
+ next unless fields.any? { |field| field['name'] == 'type' }
204
+
205
+ family = typed_family_class(typed_association_for(name)&.klass)
206
+ families[name] = family if family
207
+ end
208
+ rescue StandardError
209
+ {}
210
+ end
211
+
212
+ def typed_association_for(name)
213
+ reflections = model_class.reflect_on_all_associations(:has_many)
214
+ reflections.find { |reflection| reflection.name.to_s == name } ||
215
+ reflections.find { |reflection| reflection.name.to_s.end_with?("_#{name}") }
216
+ end
217
+
218
+ def typed_family_class(klass)
219
+ return unless klass.respond_to?(:subclass_registry) && klass.subclass_registry
220
+
221
+ klass
222
+ rescue StandardError
223
+ nil
224
+ end
225
+
103
226
  # The shape of each attribute that carries a list of records — a
104
227
  # product's `variants`, an order's `items`.
105
228
  #
@@ -3,10 +3,10 @@ require 'spree/agent_tools/context'
3
3
  require 'spree/agent_tools/resource_map'
4
4
  require 'spree/agent_tools/api_dispatch'
5
5
  require 'spree/agent_tools/membership_map'
6
- require 'spree/agent_tools/workflow_endpoints'
7
6
  require 'spree/agent_tools/record_summary'
8
- require 'spree/agent_tools/workflow_schema'
9
- require 'spree/agent_tools/workflow_tool'
7
+ require 'spree/agent_tools/legacy_tools'
8
+ require 'spree/agent_tools/operation_catalog'
9
+ require 'spree/agent_tools/operation_tool'
10
10
  require 'spree/agent_tools/default_catalog'
11
11
 
12
12
  module Spree
@@ -12,43 +12,48 @@ module Spree
12
12
  injection_points = base::INJECTION_POINTS_WITH_DEFAULTS.keys.freeze
13
13
  base.const_set(:INJECTION_POINTS, injection_points)
14
14
 
15
- injection_points.each do |point|
16
- # Original getter - returns raw value (string, class, or proc result)
17
- # BACKWARDS COMPATIBLE: Spree::Dependencies.foo.constantize still works
18
- base.attr_reader(point)
19
-
20
- # Setter with override tracking
21
- # BACKWARDS COMPATIBLE: Spree::Dependencies.foo = "MyClass" still works
22
- base.define_method("#{point}=") do |value|
23
- @overrides ||= {}
24
- @overrides[point] = {
25
- value: value,
26
- source: find_caller_source,
27
- set_at: Time.current
28
- }
29
- # Clear memoized class when value changes
30
- remove_instance_variable("@#{point}_resolved") if instance_variable_defined?("@#{point}_resolved")
31
- instance_variable_set("@#{point}", value)
32
- end
33
-
34
- # Returns resolved class with memoization
35
- # Usage: Spree::Dependencies.foo_class or Spree.foo
36
- base.define_method("#{point}_class") do
37
- return instance_variable_get("@#{point}_resolved") if instance_variable_defined?("@#{point}_resolved")
38
-
39
- value = send(point)
40
- resolved = case value
41
- when String then value.constantize
42
- when Proc then value.call.then { |v| v.is_a?(String) ? v.constantize : v }
43
- else value
44
- end
45
- # An injection point may legitimately have no default — the class is
46
- # supplied by an optional gem (search_product_presenter). Leave it
47
- # unmemoized so installing the gem later still takes effect.
48
- return nil if resolved.nil?
49
- instance_variable_set("@#{point}_resolved", resolved)
50
- resolved
51
- end
15
+ injection_points.each { |point| define_injection_point(base, point) }
16
+ end
17
+
18
+ # Defines the reader, the tracked setter and the resolving `_class` reader
19
+ # for one injection point, on a class (core points) or on one instance's
20
+ # singleton class (points an extension registers).
21
+ def self.define_injection_point(target, point)
22
+ # Original getter - returns raw value (string, class, or proc result)
23
+ # BACKWARDS COMPATIBLE: Spree::Dependencies.foo.constantize still works
24
+ target.attr_reader(point)
25
+
26
+ # Setter with override tracking
27
+ # BACKWARDS COMPATIBLE: Spree::Dependencies.foo = "MyClass" still works
28
+ target.define_method("#{point}=") do |value|
29
+ @overrides ||= {}
30
+ @overrides[point] = {
31
+ value: value,
32
+ source: find_caller_source,
33
+ set_at: Time.current
34
+ }
35
+ # Clear memoized class when value changes
36
+ remove_instance_variable("@#{point}_resolved") if instance_variable_defined?("@#{point}_resolved")
37
+ instance_variable_set("@#{point}", value)
38
+ end
39
+
40
+ # Returns resolved class with memoization
41
+ # Usage: Spree::Dependencies.foo_class or Spree.foo
42
+ target.define_method("#{point}_class") do
43
+ return instance_variable_get("@#{point}_resolved") if instance_variable_defined?("@#{point}_resolved")
44
+
45
+ value = send(point)
46
+ resolved = case value
47
+ when String then value.constantize
48
+ when Proc then value.call.then { |v| v.is_a?(String) ? v.constantize : v }
49
+ else value
50
+ end
51
+ # An injection point may legitimately have no default — the class is
52
+ # supplied by an optional gem (search_product_presenter). Leave it
53
+ # unmemoized so installing the gem later still takes effect.
54
+ return nil if resolved.nil?
55
+ instance_variable_set("@#{point}_resolved", resolved)
56
+ resolved
52
57
  end
53
58
  end
54
59
 
@@ -56,6 +61,53 @@ module Spree
56
61
  set_default_values
57
62
  end
58
63
 
64
+ # Adds an injection point an extension owns, so its own workflows and
65
+ # services can be swapped by a host app exactly like core's:
66
+ #
67
+ # Spree::Dependencies.register(:quote_send_workflow, 'MyExtension::Quotes::Send')
68
+ # Spree.quote_send_workflow # => MyExtension::Quotes::Send
69
+ #
70
+ # Register from an initializer. A host app that sets the key afterwards
71
+ # replaces the default, as with any core key. Registering a key that
72
+ # already exists raises, so two gems cannot silently claim one name.
73
+ #
74
+ # @param point [Symbol, String]
75
+ # @param default [String, Class, Proc] class name preferred, so the
76
+ # class loads on first use
77
+ # @return [Symbol] the key
78
+ def register(point, default)
79
+ point = point.to_sym
80
+ raise ArgumentError, "#{point} is already a dependency" if injection_point?(point)
81
+ # `cart_add_item_workflow_class` would shadow the resolving reader core
82
+ # defines for `cart_add_item_workflow`, a key `Spree` already answers
83
+ # itself (`search_provider`) would never be read, and an existing
84
+ # setter would lose its own behaviour to the tracked one.
85
+ names = [point, "#{point}_class", "#{point}="]
86
+ if [self, Spree].any? { |owner| names.any? { |name| owner.respond_to?(name, true) } }
87
+ raise ArgumentError, "#{point} would replace an existing method"
88
+ end
89
+
90
+ # Evaluated first, so a default that raises leaves nothing registered.
91
+ value = default.respond_to?(:call) ? default.call : default
92
+ registered_points[point] = default
93
+ Spree::DependenciesHelper.define_injection_point(singleton_class, point)
94
+ instance_variable_set("@#{point}", value)
95
+ point
96
+ end
97
+
98
+ # Core's injection points plus the ones extensions registered.
99
+ #
100
+ # @return [Array<Symbol>]
101
+ def injection_points
102
+ self.class::INJECTION_POINTS + registered_points.keys
103
+ end
104
+
105
+ # @param point [Symbol, String]
106
+ # @return [Boolean]
107
+ def injection_point?(point)
108
+ injection_points.include?(point.to_sym)
109
+ end
110
+
59
111
  # Returns hash of all overridden dependencies with metadata
60
112
  def overrides
61
113
  @overrides || {}
@@ -73,8 +125,8 @@ module Spree
73
125
 
74
126
  # Returns array of all dependencies with current values and metadata
75
127
  def current_values
76
- self.class::INJECTION_POINTS.map do |point|
77
- default = self.class::INJECTION_POINTS_WITH_DEFAULTS[point]
128
+ injection_points.map do |point|
129
+ default = self.class::INJECTION_POINTS_WITH_DEFAULTS.fetch(point) { registered_points[point] }
78
130
  default_val = default.respond_to?(:call) ? default.call : default
79
131
  current = send(point)
80
132
 
@@ -94,7 +146,7 @@ module Spree
94
146
  # Raises Spree::DependencyError if any dependency is invalid
95
147
  def validate!
96
148
  errors = []
97
- self.class::INJECTION_POINTS.each do |point|
149
+ injection_points.each do |point|
98
150
  send("#{point}_class")
99
151
  rescue NameError => e
100
152
  errors << { name: point, value: send(point), error: e.message }
@@ -106,6 +158,10 @@ module Spree
106
158
 
107
159
  private
108
160
 
161
+ def registered_points
162
+ @registered_points ||= {}
163
+ end
164
+
109
165
  def set_default_values
110
166
  self.class::INJECTION_POINTS_WITH_DEFAULTS.each do |injection_point, default_value|
111
167
  value = default_value.respond_to?(:call) ? default_value.call : default_value