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.
- checksums.yaml +4 -4
- data/app/jobs/spree/addresses/geocode_address_job.rb +6 -3
- data/app/jobs/spree/variants/remove_line_item_job.rb +5 -0
- data/app/models/concerns/spree/preference_schema.rb +5 -0
- data/app/models/concerns/spree/typed_associations.rb +53 -20
- data/app/models/spree/address.rb +2 -2
- data/app/models/spree/collection.rb +7 -2
- data/app/models/spree/order.rb +1 -1
- data/app/models/spree/product.rb +15 -5
- data/app/models/spree/promotion.rb +1 -2
- data/app/services/spree/newsletter/subscribe.rb +1 -1
- data/app/services/spree/orders/create_from_cart.rb +6 -2
- data/app/tools/spree/agent_tools/create_export.rb +3 -1
- data/app/tools/spree/agent_tools/create_import.rb +1 -0
- data/app/tools/spree/agent_tools/create_media.rb +2 -1
- data/app/tools/spree/agent_tools/create_resource.rb +12 -4
- data/app/tools/spree/agent_tools/delete_resource.rb +12 -4
- data/app/tools/spree/agent_tools/describe_resource.rb +69 -6
- data/app/tools/spree/agent_tools/get_resource.rb +7 -4
- data/app/tools/spree/agent_tools/resource_write.rb +15 -2
- data/app/tools/spree/agent_tools/search_resources.rb +10 -11
- data/app/tools/spree/agent_tools/update_resource.rb +6 -6
- data/app/tools/spree/agent_tools/upload_file.rb +1 -0
- data/app/workflows/spree/carts/complete.rb +4 -1
- data/app/workflows/spree/carts/merge.rb +25 -4
- data/app/workflows/spree/orders/complete.rb +39 -14
- data/app/workflows/spree/products/activate.rb +7 -2
- data/app/workflows/spree/products/archive.rb +7 -2
- data/app/workflows/spree/products/draft.rb +7 -2
- data/app/workflows/spree/products/status_transition.rb +24 -0
- data/config/locales/en.yml +3 -0
- data/lib/generators/spree/api_resource/api_resource_generator.rb +35 -3
- data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +9 -4
- data/lib/generators/spree/api_resource/templates/admin_integration_spec.rb.tt +74 -0
- data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +11 -6
- data/lib/spree/agent_tool.rb +105 -1
- data/lib/spree/agent_tools/default_catalog.rb +16 -21
- data/lib/spree/agent_tools/legacy_tools.rb +212 -0
- data/lib/spree/agent_tools/operation_catalog.rb +206 -0
- data/lib/spree/agent_tools/operation_tool.rb +189 -0
- data/lib/spree/agent_tools/registry.rb +65 -31
- data/lib/spree/agent_tools/resource_map.rb +128 -5
- data/lib/spree/agent_tools.rb +3 -3
- data/lib/spree/core/dependencies_helper.rb +96 -40
- data/lib/spree/core/engine.rb +2 -1
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +19 -2
- data/lib/spree/testing_support/extension_rake.rb +12 -0
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +34 -0
- data/lib/tasks/dependencies.rake +2 -2
- metadata +23 -7
- data/lib/spree/agent_tools/workflow_endpoints.rb +0 -88
- data/lib/spree/agent_tools/workflow_schema.rb +0 -279
- 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.
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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
|
-
#
|
|
65
|
-
# the
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
# @
|
|
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
|
-
|
|
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
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
#
|
|
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 =
|
|
96
|
-
return [] if file.nil?
|
|
164
|
+
file = serializer_file(klass)
|
|
165
|
+
return [] if file.nil?
|
|
97
166
|
|
|
98
|
-
|
|
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
|
#
|
data/lib/spree/agent_tools.rb
CHANGED
|
@@ -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/
|
|
9
|
-
require 'spree/agent_tools/
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
#
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|