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,131 @@
1
+ module Spree
2
+ module AgentTools
3
+ # The agent-tool registry. One per surface, the way the authentication
4
+ # strategies are split — {Spree.admin_agent_tools} for the back office,
5
+ # {Spree.store_agent_tools} for the storefront. Extensions append their
6
+ # own tool classes the same way they register tax or delivery-rate
7
+ # providers:
8
+ #
9
+ # Spree.admin_agent_tools << MyApp::AgentTools::SyncSupplier
10
+ #
11
+ # Registration is by class, so one registry serves every adapter on its
12
+ # surface: the MCP server and the dashboard assistant both read the admin
13
+ # one.
14
+ #
15
+ # A tool also declares the surface it belongs to, so filing one in the
16
+ # wrong registry is refused rather than discovered when a storefront
17
+ # visitor is offered the back office. Ten admin tools carry no permission
18
+ # of their own — they are gated per resource instead — so the registry
19
+ # cannot be the only thing keeping them apart.
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}.
25
+ class Registry
26
+ include Enumerable
27
+
28
+ # @return [Symbol] the surface this registry serves
29
+ attr_reader :audience
30
+
31
+ def initialize(tool_names = [], audience: :admin)
32
+ @tool_names = tool_names.map(&:to_s)
33
+ @exposed_workflows = {}
34
+ @audience = audience.to_sym
35
+ end
36
+
37
+ # Registered by name, resolved on use.
38
+ #
39
+ # Holding the class itself would pin the copy loaded at boot, so in
40
+ # development every edit to a tool's description, params or permission
41
+ # needed a restart to take effect — and tuning those descriptions is how
42
+ # an agent's behaviour is shaped. Names survive reloading, which is the
43
+ # same reason `Spree.integrations` stores strings.
44
+ #
45
+ # @param tool_class [Class<Spree::AgentTool>, String]
46
+ # @return [self]
47
+ def <<(tool_class)
48
+ name = tool_class.to_s
49
+ declared = tool_class.try(:audience)
50
+ if declared && declared != audience
51
+ raise ArgumentError,
52
+ "#{name} belongs to the #{declared} surface and cannot be registered on the #{audience} one"
53
+ end
54
+
55
+ @tool_names << name unless @tool_names.include?(name)
56
+ self
57
+ end
58
+ alias add <<
59
+
60
+ def each(&)
61
+ to_a.each(&)
62
+ end
63
+
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).
75
+ #
76
+ # @param exposures [Hash{Symbol => String, Hash}]
77
+ # @return [self]
78
+ 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
87
+ self
88
+ end
89
+
90
+ # @return [Hash{Symbol => Hash}] every exposed workflow, by dependency key
91
+ attr_reader :exposed_workflows
92
+
93
+ # @return [Array<Class<Spree::AgentTool>>]
94
+ def to_a
95
+ registered_classes + workflow_tool_classes
96
+ end
97
+
98
+ # Tools this caller may actually use, instantiated for their context.
99
+ # This is what an adapter offers the model — a caller without
100
+ # `write_products` never learns that a price-changing tool exists.
101
+ #
102
+ # @param context [Spree::AgentTools::Context]
103
+ # @return [Array<Spree::AgentTool>]
104
+ def available_for(context)
105
+ to_a.
106
+ select { |tool_class| tool_class.try(:audience).nil? || tool_class.audience == audience }.
107
+ map { |tool_class| tool_class.new(context) }.
108
+ select(&:permitted?)
109
+ end
110
+
111
+ private
112
+
113
+ def registered_classes
114
+ @tool_names.filter_map do |name|
115
+ name.safe_constantize.tap do |klass|
116
+ Rails.logger.warn("[Spree] registered agent tool #{name} does not exist") if klass.nil?
117
+ end
118
+ end
119
+ end
120
+
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)
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,352 @@
1
+ module Spree
2
+ module AgentTools
3
+ # What the generic tools are allowed to look at.
4
+ #
5
+ # Not a hand-written list: the Admin API's controllers already declare
6
+ # their model, serializer and permission scope, so the map is derived from
7
+ # them — `spree_api` registers one entry per admin resource controller
8
+ # after the classes load. Core owns the structure and the refusal below;
9
+ # a resource no controller serves is not reachable by any agent.
10
+ #
11
+ # Permissions therefore mirror the controllers by construction rather than
12
+ # by convention: the catalog has no `read_price_lists`, and the price-lists
13
+ # controller rides `read_products`, so the derived entry does too.
14
+ class ResourceMap
15
+ # A registered resource whose model has no store scoping. Raised rather
16
+ # than quietly leaking another store's records.
17
+ UnscopedResourceError = Class.new(StandardError)
18
+
19
+ Entry = Struct.new(:key, :model_name, :permission, :serializer_name,
20
+ :dashboard_path, :scope_name, :write_permission,
21
+ :writable_attributes, :create_workflow_key, :update_workflow_key,
22
+ :distinct, :api_paths, keyword_init: true) do
23
+ # The Admin API path for one of this resource's operations, which is
24
+ # how a tool reaches it.
25
+ #
26
+ # @param action [Symbol] :index, :show, :create, :update, :destroy
27
+ # @return [String, nil]
28
+ def api_path(action)
29
+ Array(api_paths).to_h[action.to_sym]
30
+ end
31
+
32
+ # @return [Boolean] whether a tool can dispatch to this resource
33
+ def dispatchable?
34
+ api_path(:index).present?
35
+ end
36
+
37
+ # Whether a search may add DISTINCT.
38
+ #
39
+ # Postgres rejects SELECT DISTINCT beside an ORDER BY the select list
40
+ # does not carry, which is why some controllers turn it off. Carried
41
+ # from the controller rather than decided here, so a resource that
42
+ # changes its mind is followed without a second list to maintain.
43
+ #
44
+ # @return [Boolean]
45
+ def distinct?
46
+ distinct.nil? ? true : distinct
47
+ end
48
+
49
+ # Whether the generic write tools may touch this resource.
50
+ #
51
+ # A declared workflow no longer excludes it: the generic writes
52
+ # dispatch to the controller, which runs its own workflow, so there
53
+ # is still exactly one way to write each thing and this is it.
54
+ # What is left to answer is whether the endpoint accepts a body at
55
+ # all.
56
+ #
57
+ # @return [Boolean]
58
+ def generic_writes?
59
+ write_permission.present?
60
+ end
61
+
62
+ # The attributes the Admin API would accept on a write, so the generic
63
+ # tools offer exactly the same surface as the endpoint.
64
+ #
65
+ # @return [Array<String>]
66
+ def writable_attribute_names
67
+ Array(writable_attributes).map(&:to_s).sort
68
+ end
69
+
70
+ # The associations the show operation will include when asked, read
71
+ # from the serializer that renders it.
72
+ #
73
+ # A record's associations are left out by default because a full
74
+ # admin payload is more than a model's context should hold. Without
75
+ # knowing the names, a model cannot ask for the one it needs — it
76
+ # fetched a product and saw no variants at all.
77
+ #
78
+ # @return [Array<String>]
79
+ def expandable_associations
80
+ @expandable_associations ||= begin
81
+ # Walked up the chain rather than read off one file: a serializer
82
+ # inherits most of its conditional associations from the Store
83
+ # API one it extends.
84
+ classes = Array(serializer_class&.ancestors).select do |ancestor|
85
+ ancestor.is_a?(Class) && ancestor.name.to_s.include?('Serializer')
86
+ end
87
+
88
+ classes.flat_map { |klass| expand_names_in(klass) }.uniq.sort
89
+ rescue StandardError
90
+ []
91
+ end
92
+ end
93
+
94
+ def expand_names_in(klass)
95
+ file = Object.const_source_location(klass.name)&.first
96
+ return [] if file.nil? || !File.exist?(file)
97
+
98
+ File.read(file).scan(/expand\?\('([a-z_]+)'\)/).flatten
99
+ rescue StandardError
100
+ []
101
+ end
102
+
103
+ # The shape of each attribute that carries a list of records — a
104
+ # product's `variants`, an order's `items`.
105
+ #
106
+ # A model that is only told the key exists sends the records one at a
107
+ # time to their own endpoints instead, which costs several round trips
108
+ # and, where the parent reconciles its list, leaves behind whatever
109
+ # the first write created.
110
+ #
111
+ # @return [Hash{String => Array<String>}] attribute => the keys of one row
112
+ def nested_collection_shapes
113
+ return {} unless defined?(Spree::Api::AgentWriteSchemas)
114
+
115
+ Spree::Api::AgentWriteSchemas.for(key).each_with_object({}) do |(name, schema), shapes|
116
+ next unless schema.is_a?(Hash) && schema['type'] == 'array'
117
+
118
+ # A row's shape is published as a component too, so the array
119
+ # points at one rather than describing its items inline.
120
+ row = Spree::Api::AgentWriteSchemas.resolve_schema(schema['items'])
121
+ properties = row&.dig('properties')
122
+ next unless properties.is_a?(Hash) && properties.any?
123
+
124
+ # Names alone left an agent to guess: it sent a number where the
125
+ # endpoint wanted a decimal string, and omitted a field the model
126
+ # requires, learning both from a 422. The type and the
127
+ # requirement are already in the published schema.
128
+ required = Array(row['required'])
129
+ shapes[name.to_s] = properties.keys.sort.map do |field|
130
+ { 'name' => field,
131
+ 'type' => properties.dig(field, 'type'),
132
+ 'required' => required.include?(field) }
133
+ end
134
+ end
135
+ rescue StandardError
136
+ {}
137
+ end
138
+ # Where this record lives in the dashboard, so an answer links to it
139
+ # rather than describing where to look. Store-relative — the panel
140
+ # prefixes the current store segment.
141
+ #
142
+ # @param record [ActiveRecord::Base]
143
+ # @return [String, nil]
144
+ def dashboard_path_for(record)
145
+ return if dashboard_path.blank?
146
+
147
+ format(dashboard_path, id: record.prefixed_id)
148
+ end
149
+
150
+ # @return [Class]
151
+ def model_class
152
+ model_name.constantize
153
+ end
154
+
155
+ # Records of this kind the asking admin may see.
156
+ #
157
+ # Takes the context rather than the store so the ability filter cannot
158
+ # be skipped by forgetting to apply it: store scoping answers tenancy,
159
+ # `accessible` answers which records within that tenant — the same two
160
+ # steps an Admin API controller takes.
161
+ #
162
+ # @param context [Spree::AgentTools::Context]
163
+ # @param action [Symbol]
164
+ # @return [ActiveRecord::Relation]
165
+ def scope_for(context, action = :show)
166
+ relation = model_class.for_store(context.store)
167
+
168
+ # `Spree::Base.for_store` falls back to the bare class when a model
169
+ # has no matching Store association, returning EVERY store's rows.
170
+ # A model that defines its own `for_store` has made a deliberate
171
+ # choice (customers are global by design, and Spree::Customer says
172
+ # so); inheriting the fallback is an accident. Only the accident is
173
+ # refused, because serving another store's records to an assistant
174
+ # that reads them aloud is worse than a missing resource.
175
+ if model_class.method(:for_store).owner == Spree::Base.singleton_class && relation.equal?(model_class)
176
+ raise UnscopedResourceError, key
177
+ end
178
+
179
+ # A resource that is a slice of a model rather than the whole of it
180
+ # (draft orders among orders) narrows further.
181
+ relation = relation.public_send(scope_name) if scope_name.present?
182
+
183
+ context.accessible(relation, action)
184
+ end
185
+
186
+ # Whether this resource's scoping is deliberate — either a real
187
+ # store relation, or a model that owns its `for_store` and has decided
188
+ # it is global.
189
+ #
190
+ # @return [Boolean]
191
+ def store_scoped?
192
+ model_class.method(:for_store).owner != Spree::Base.singleton_class ||
193
+ !model_class.for_store(Spree::Store.new).equal?(model_class)
194
+ rescue StandardError
195
+ false
196
+ end
197
+
198
+ # @return [Class] the admin serializer for this resource
199
+ def serializer_class
200
+ serializer_name.constantize
201
+ end
202
+
203
+ # Fields the model actually allows filtering and sorting on. Reused from
204
+ # the model's own Ransack allowlist so the assistant can never query a
205
+ # field the API would refuse.
206
+ #
207
+ # @return [Array<String>]
208
+ def filterable_fields
209
+ model_class.ransackable_attributes.sort
210
+ end
211
+
212
+ # Named queries the model exposes — `in_stock`, `out_of_stock`,
213
+ # `price_lte` and so on. These answer the questions merchants actually
214
+ # ask ("what's out of stock?") and cannot be expressed as an attribute
215
+ # filter, because the answer lives across stock levels rather than in a
216
+ # column. Passed as bare keys rather than `field_predicate` pairs.
217
+ #
218
+ # @return [Array<String>]
219
+ def filterable_scopes
220
+ model_class.ransackable_scopes.map(&:to_s).sort
221
+ end
222
+ end
223
+
224
+
225
+ class << self
226
+ # How the map fills itself the first time it is read. `spree_api` sets
227
+ # this to its controller walk; core owns only the structure, so an
228
+ # installation without the API engine simply has an empty map.
229
+ #
230
+ # @return [#call, nil]
231
+ attr_accessor :deriver
232
+
233
+ # @return [Array<Entry>] every registered resource, in registration order
234
+ def all
235
+ derive!
236
+ entries.values
237
+ end
238
+
239
+ # Registers one resource. Called by `spree_api` for each admin resource
240
+ # controller; an extension with its own admin controller is picked up
241
+ # by the same walk, so nothing registers by hand.
242
+ #
243
+ # Re-registering a key replaces it, because the walk runs again on every
244
+ # code reload in development.
245
+ #
246
+ # @param key [String, Symbol] the resource key agents address it by
247
+ # @param model_name [String]
248
+ # @param permission [String] the `read_*` key gating it
249
+ # @param serializer_name [String]
250
+ # @param dashboard_path [String, nil] a `%<id>s` format string
251
+ # @param scope_name [Symbol, nil] narrows the model to a slice of itself
252
+ # @param write_permission [String, nil] the `write_*` key, absent when
253
+ # the controller offers no writes
254
+ # @param writable_attributes [Array<Symbol>] what a write may set
255
+ # @param create_workflow_key [String, nil] set when the controller
256
+ # declares a create workflow, which the generic write defers to
257
+ # @param update_workflow_key [String, nil] likewise for updates
258
+ # @param distinct [Boolean, nil] false where the controller disables
259
+ # DISTINCT on its own collection, which Postgres requires when the
260
+ # sort column is not in the select list
261
+ # @return [Entry]
262
+ def register(key:, model_name:, permission:, serializer_name:, dashboard_path: nil, scope_name: nil,
263
+ write_permission: nil, writable_attributes: [], create_workflow_key: nil,
264
+ update_workflow_key: nil, distinct: nil, api_paths: nil)
265
+ entry = Entry.new(key: key.to_s, model_name: model_name, permission: permission,
266
+ serializer_name: serializer_name, dashboard_path: dashboard_path,
267
+ scope_name: scope_name, write_permission: write_permission,
268
+ writable_attributes: writable_attributes,
269
+ create_workflow_key: create_workflow_key,
270
+ update_workflow_key: update_workflow_key,
271
+ distinct: distinct,
272
+ api_paths: api_paths&.to_a)
273
+ entries[entry.key] = entry
274
+ end
275
+
276
+ # Forgets every registration, so the derivation can run again from
277
+ # scratch on a code reload.
278
+ #
279
+ # @return [void]
280
+ def reset!
281
+ @entries = {}
282
+ end
283
+
284
+ # @param key [String]
285
+ # @return [Entry, nil]
286
+ def find(key)
287
+ derive!
288
+ entries[key.to_s]
289
+ end
290
+
291
+ # Marks the map for rebuilding on its next read. Called on every code
292
+ # reload, because a controller edited in development changes what the
293
+ # map should say — but the rebuild itself waits for a reader.
294
+ #
295
+ # @return [void]
296
+ def stale!
297
+ @derived = false
298
+ end
299
+
300
+ # Fills the map once, from whatever `deriver` was registered.
301
+ #
302
+ # Under a mutex, because the deriver rebuilds in place — it clears the
303
+ # map and registers each entry — so a second thread reading between
304
+ # those two moments would see an empty map and report every resource
305
+ # as unknown.
306
+ #
307
+ # The flag is cleared again if the deriver raises: the map is empty by
308
+ # then, and leaving it marked derived would answer "unknown resource"
309
+ # for the rest of the process, since `stale!` only runs at boot and on
310
+ # reload. A failed derivation is retried by the next caller instead.
311
+ #
312
+ # @return [void]
313
+ def derive!
314
+ return if @derived || deriver.nil?
315
+
316
+ derive_mutex.synchronize do
317
+ return if @derived
318
+
319
+ # Set before calling, so a deriver that reads the map while filling
320
+ # it does not recurse.
321
+ @derived = true
322
+ begin
323
+ deriver.call
324
+ rescue StandardError
325
+ @derived = false
326
+ raise
327
+ end
328
+ end
329
+ end
330
+
331
+ # Resources this caller may read, so a tool description lists only what
332
+ # they can actually ask about.
333
+ #
334
+ # @param context [Spree::AgentTools::Context]
335
+ # @return [Array<Entry>]
336
+ def available_for(context)
337
+ all.select { |entry| context.permitted?(entry.permission) }
338
+ end
339
+
340
+ private
341
+
342
+ def entries
343
+ @entries ||= {}
344
+ end
345
+
346
+ def derive_mutex
347
+ @derive_mutex ||= Mutex.new
348
+ end
349
+ end
350
+ end
351
+ end
352
+ end
@@ -0,0 +1,88 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Which Admin API operation runs each workflow an agent may call.
4
+ #
5
+ # A contract like {ResourceMap} and {MembershipMap}: core declares the
6
+ # shape, and `spree_api` fills it by reading its own routes and
7
+ # controllers, so a workflow that gains an endpoint is dispatched without
8
+ # anyone editing a list.
9
+ #
10
+ # A workflow with no entry runs in this process instead. That is not a
11
+ # fallback to be relied on — it means the capability has no endpoint yet,
12
+ # and the tool is the only way to reach it.
13
+ module WorkflowEndpoints
14
+ # @!attribute verb
15
+ # @return [String] 'POST', 'PATCH'
16
+ # @!attribute path
17
+ # @return [String] with `:name` segments still in it
18
+ # @!attribute body_keys
19
+ # @return [Array<String>] what the operation's request body accepts,
20
+ # read from the generated OpenAPI document — which is where the
21
+ # renames live (a cancellation's `reason:` keyword is
22
+ # `cancel_reason_id` on the wire)
23
+ Entry = Struct.new(:verb, :path, :body_keys, keyword_init: true) do
24
+ # The dynamic segments, in the order the path names them.
25
+ #
26
+ # @return [Array<String>]
27
+ def path_params
28
+ @path_params ||= path.scan(/:([a-z_]+)/).flatten
29
+ end
30
+
31
+ # @param values [Hash] path parameter name => id
32
+ # @return [String]
33
+ def path_for(values)
34
+ path.gsub(/:([a-z_]+)/) { values.fetch(Regexp.last_match(1)).to_s }
35
+ end
36
+ end
37
+
38
+ class << self
39
+ # @param key [Symbol] a `Spree::Dependencies` workflow key
40
+ # @return [Entry, nil]
41
+ def find(key)
42
+ derive!
43
+ entries[key.to_sym]
44
+ end
45
+
46
+ # @return [Hash{Symbol => Entry}]
47
+ def all
48
+ derive!
49
+ entries
50
+ end
51
+
52
+ def register(key:, verb:, path:, body_keys: [])
53
+ entries[key.to_sym] = Entry.new(verb: verb.to_s.upcase, path: path,
54
+ body_keys: body_keys.map(&:to_s))
55
+ end
56
+
57
+ def reset!
58
+ @entries = {}
59
+ @derived = false
60
+ end
61
+
62
+ # What has been registered so far, without deriving — for the
63
+ # deriver itself, which must not ask for what it is producing.
64
+ #
65
+ # @return [Hash{Symbol => Entry}]
66
+ def registered
67
+ entries
68
+ end
69
+
70
+ # Filled on first read rather than at boot: deriving it loads every
71
+ # admin controller, which a developer who never runs an agent should
72
+ # not pay for on each code reload.
73
+ def derive!
74
+ return if @derived
75
+
76
+ @derived = true
77
+ Spree::Api::AgentWorkflowEndpoints.install if defined?(Spree::Api::AgentWorkflowEndpoints)
78
+ end
79
+
80
+ private
81
+
82
+ def entries
83
+ @entries ||= {}
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end