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
@@ -21,6 +21,12 @@ module Spree
21
21
  # end
22
22
  # end
23
23
  class AgentTool
24
+ # The parameter every generic resource tool takes for a resource that
25
+ # lives under another record, such as an order's items.
26
+ PARENT_ID_DESCRIPTION = 'For a resource reached through another record (describe_resource names ' \
27
+ 'it as parent, e.g. an order for its items, discounts and fees): that ' \
28
+ 'record\'s id as shown in search results'.freeze
29
+
24
30
  # Declarations live in class attributes rather than plain class-level
25
31
  # instance variables so a subclass inherits them. Subclassing a tool to
26
32
  # tweak it is the documented extension path, and with bare ivars the
@@ -85,6 +91,26 @@ class AgentTool
85
91
  declared_mutating == true
86
92
  end
87
93
 
94
+ # Declares that this tool only adds — it creates a record or a file and
95
+ # never overwrites, removes or changes the state of one that exists.
96
+ # Every other mutating tool counts as destructive, so a tool that
97
+ # forgets to say is warned about more, never less.
98
+ #
99
+ # Unlike the declarations above, this one is not inherited: a subclass
100
+ # that overrides `call` may well change existing data, so it is
101
+ # destructive until it says otherwise itself.
102
+ #
103
+ # @return [void]
104
+ def additive!
105
+ @additive = true
106
+ end
107
+
108
+ # @return [Boolean] whether the tool may overwrite, remove or change
109
+ # the state of existing data
110
+ def destructive?
111
+ mutating? && @additive != true
112
+ end
113
+
88
114
  # @param name [Symbol]
89
115
  # @param type [Symbol] JSON schema type
90
116
  # @param description [String]
@@ -105,6 +131,44 @@ class AgentTool
105
131
  def params
106
132
  declared_params
107
133
  end
134
+
135
+ # The declared params as JSON Schema, which is what an adapter hands a
136
+ # client. A tool built from an OpenAPI operation overrides this with the
137
+ # operation's own schema, nested properties included.
138
+ #
139
+ # @return [Hash]
140
+ def input_schema
141
+ properties = params.to_h do |name, options|
142
+ property = { 'type' => json_type(options[:type]) }
143
+ property['description'] = options[:description] if options[:description].present?
144
+ # An array must say what it holds: a client validating arguments
145
+ # against the schema refuses the call otherwise.
146
+ property['items'] = { 'type' => json_type(options[:items] || :string) } if property['type'] == 'array'
147
+
148
+ [name.to_s, property]
149
+ end
150
+
151
+ {
152
+ 'type' => 'object',
153
+ 'properties' => properties,
154
+ 'required' => params.select { |_name, options| options[:required] }.keys.map(&:to_s)
155
+ }
156
+ end
157
+
158
+ private
159
+
160
+ # A tool declares Ruby-ish types; JSON Schema wants its own names, and
161
+ # anything unrecognised is a string, which every client can render.
162
+ def json_type(type)
163
+ case type&.to_sym
164
+ when :boolean then 'boolean'
165
+ when :integer then 'integer'
166
+ when :number, :float, :decimal then 'number'
167
+ when :object, :hash then 'object'
168
+ when :array then 'array'
169
+ else 'string'
170
+ end
171
+ end
108
172
  end
109
173
 
110
174
  # @return [Spree::AgentTools::Context] who is asking, and where
@@ -115,7 +179,7 @@ class AgentTool
115
179
  @context = context
116
180
  end
117
181
 
118
- delegate :tool_name, :description, :permission, :mutating?, :params, to: :class
182
+ delegate :tool_name, :description, :permission, :mutating?, :destructive?, :params, to: :class
119
183
 
120
184
  # Runs the tool. Subclasses implement this with keyword arguments matching
121
185
  # their declared params.
@@ -167,6 +231,46 @@ class AgentTool
167
231
 
168
232
  protected
169
233
 
234
+ # A resource that lives under a parent — a promotion's rules, an order's
235
+ # payments — has no path of its own to dispatch to, so every tool refuses
236
+ # it the same way: naming the parent and, where the parent carries the
237
+ # rows as an attribute, how to write and read them there. The old answer
238
+ # was the endpoint's 404 for a parent id nobody supplied.
239
+ #
240
+ # @param entry [Spree::AgentTools::ResourceMap::Entry]
241
+ # @param verb [String] 'searched', 'fetched', 'created'…
242
+ # @param action [Symbol] the operation whose path runs under the parent
243
+ # @return [Hash]
244
+ def nested_resource_error(entry, verb, action)
245
+ # A top-level path left unfilled is missing its own id, not a parent.
246
+ return { error: "Pass the id of the #{entry.key.singularize.tr('_', ' ')} to use." } unless entry.nested?(action)
247
+
248
+ parent = entry.parent_key(action)
249
+ parent_entry = Spree::AgentTools::ResourceMap.find(parent)
250
+ attribute = parent_entry&.nested_typed_families&.key(entry.model_class)
251
+
252
+ owner = parent.singularize.tr('_', ' ')
253
+ sentences = ["#{entry.key} belong to #{owner.match?(/\A[aeiou]/) ? 'an' : 'a'} #{owner} and cannot be #{verb} on their own."]
254
+ # Offered only where one parent id fills the path; a resource nested
255
+ # twice has no generic route.
256
+ reachable = entry.path_for(action, id: 'id', parent_id: 'parent').present?
257
+ sentences << "Pass the #{owner}'s id as parent_id to reach them under it." if reachable
258
+ # Each suggested call is one this caller may actually make; a hint
259
+ # that leads straight to a permission refusal is worse than none.
260
+ readable = parent_entry && context.permitted?(parent_entry.permission)
261
+ writable = parent_entry&.write_permission.present? && context.permitted?(parent_entry.write_permission)
262
+ if attribute && writable
263
+ sentences << "Write them in the #{attribute} attribute of #{parent} — describe_resource #{parent} shows the shape and the accepted types."
264
+ end
265
+ if attribute && readable && parent_entry.expandable_associations.include?(attribute)
266
+ sentences << "Read them with get_resource on #{parent} and expand: [\"#{attribute}\"]."
267
+ elsif readable && !attribute
268
+ sentences << "Start from #{parent}: get_resource with expand lists what it carries (describe_resource #{parent} names the options)."
269
+ end
270
+
271
+ { error: sentences.join(' '), parent: parent }
272
+ end
273
+
170
274
  # Refuses unless this admin may take `action` on `record`.
171
275
  #
172
276
  # Holding `write_products` says an admin may edit products; it does not
@@ -1,15 +1,11 @@
1
1
  module Spree
2
2
  module AgentTools
3
- # The tools Spree ships: the generic read tools, and the workflows exposed
4
- # as write tools.
3
+ # The hand-written tools Spree ships: the generic reads and writes,
4
+ # reporting, imports, files and stock.
5
5
  #
6
- # The write allowlist is the whole catalog of back-office writes an agent
7
- # may make. Every entry names a `Spree::Dependencies` key, so a host app
8
- # that swaps in its own workflow class keeps the tool and gets the
9
- # replacement's schema; and a permission, which must equal the write scope
10
- # of the admin controller that invokes the same workflow — a contract spec
11
- # in `spree_api` fails when the two disagree, or when a controller gains a
12
- # workflow that is neither listed here nor excluded below.
6
+ # Back-office actions are not listed here. Every Admin API action is a
7
+ # tool built from its OpenAPI operation ({OperationCatalog}); an action
8
+ # that should not be one is marked in its rswag spec, with the reason.
13
9
  module DefaultCatalog
14
10
  # Read tools, and the writes that are not workflows.
15
11
  TOOLS = %w[
@@ -33,15 +29,11 @@ module Spree
33
29
  ].freeze
34
30
 
35
31
 
36
- # Workflow key => permission, or => {permission:, except:, summary:}.
32
+ # @deprecated The 6.0.0 workflow allowlist, kept only so a reference to
33
+ # it does not break through 6.0.x. Nothing reads it: Admin API
34
+ # operations are tools on their own. Removed in 6.1.
37
35
  WORKFLOWS = {
38
36
  # Catalog
39
- #
40
- # The three status moves below run in this process rather than through
41
- # an endpoint, because the Admin API has none for them: `PATCH` writes
42
- # the column and the bulk action uses `update_all`, so neither
43
- # publishes the status event nor withdraws an open seller submission.
44
- # They dispatch as soon as an admin status endpoint exists.
45
37
  product_activate_workflow: 'write_products',
46
38
  product_draft_workflow: 'write_products',
47
39
  product_archive_workflow: 'write_products',
@@ -59,8 +51,10 @@ module Spree
59
51
 
60
52
  # Orders. `restock_items` is hidden: it is deprecated and ignored, so
61
53
  # offering it would invite a model to set something that does nothing.
54
+ # `validated` is for callers that already ran their own veto; offered
55
+ # to a model it would be a way around orders.complete.validate.
62
56
  order_cancel_workflow: { permission: 'write_orders', except: %i[restock_items] },
63
- order_complete_workflow: 'write_orders',
57
+ order_complete_workflow: { permission: 'write_orders', except: %i[validated] },
64
58
 
65
59
  # Fulfillment
66
60
  fulfillment_create_workflow: 'write_fulfillments',
@@ -103,9 +97,9 @@ module Spree
103
97
  tax_exemption_certificate_verify_workflow: 'write_customers'
104
98
  }.freeze
105
99
 
106
- # Workflows an admin controller invokes that are deliberately NOT tools,
107
- # each with the reason. The contract spec reads this list, so a workflow
108
- # can never be quietly dropped: it is exposed, or it is here.
100
+ # @deprecated The 6.0.0 exclusion list, kept like {WORKFLOWS}. Its
101
+ # reasons now travel as `agent_tool false, reason:` on the operation's
102
+ # rswag spec. Removed in 6.1.
109
103
  # The email template editor is a draft-and-publish surface: its steps
110
104
  # move a draft through revisions against a live preview, and they mean
111
105
  # nothing outside that screen. Publishing one sends real mail in the
@@ -172,9 +166,10 @@ module Spree
172
166
  # @return [void]
173
167
  def install(registry)
174
168
  TOOLS.each { |tool| registry << tool }
175
- registry.expose_workflows(**WORKFLOWS)
176
169
  end
177
170
  end
171
+
172
+ deprecate_constant :WORKFLOWS, :EXCLUDED_WORKFLOWS
178
173
  end
179
174
  end
180
175
  end
@@ -0,0 +1,212 @@
1
+ module Spree
2
+ module AgentTools
3
+ # The workflow tools Spree 6.0.0 offered, by the name and parameters a
4
+ # client saw then, and the operation each now runs.
5
+ #
6
+ # 6.0.0 derived a tool from a workflow's `perform` signature, so its
7
+ # parameters were the workflow's keywords (`order`, `reason`); a tool now
8
+ # takes the operation's own (`id`, `cancel_reason_id`). This table, written
9
+ # once from the 6.0.0 schemas, keeps those calls working through 6.0.x. It
10
+ # is an explicit list, not a rule: nothing here guesses one name from
11
+ # another.
12
+ #
13
+ # A parameter mapped to nil never reached the endpoint in 6.0.0 either; it
14
+ # is ignored, and the result says so. One mapped to `:merge` was a hash of
15
+ # fields, merged into the body, which is what 6.0.0 did with it.
16
+ #
17
+ # `deliveries_update_tracking` is not here: 6.0.0 sent it to the
18
+ # mark-delivered endpoint and dropped its tracking fields, so a call meant
19
+ # to record tracking marked the delivery delivered. No endpoint sets a
20
+ # carrier status, so the name no longer answers rather than doing that.
21
+ #
22
+ # Removed in 6.1, with the names that are no longer listed.
23
+ # See docs/plans/6.0-agent-tools-from-openapi.md.
24
+ module LegacyTools
25
+ # Old tool name => the operation it runs and its renamed parameters.
26
+ TOOLS = {
27
+ 'catalogs_activate' => {
28
+ verb: 'patch', path: '/api/v3/admin/catalogs/{id}/activate',
29
+ parameters: { 'catalog' => 'id' }
30
+ },
31
+ 'catalogs_deactivate' => {
32
+ verb: 'patch', path: '/api/v3/admin/catalogs/{id}/deactivate',
33
+ parameters: { 'catalog' => 'id' }
34
+ },
35
+ 'fulfillments_create' => {
36
+ verb: 'post', path: '/api/v3/admin/orders/{order_id}/fulfillments',
37
+ parameters: {
38
+ 'order' => 'order_id', 'stock_location' => 'stock_location_id',
39
+ 'source_fulfillment' => 'source_fulfillment_id', 'delivery_method' => 'delivery_method_id'
40
+ }
41
+ },
42
+ 'fulfillments_mark_delivered' => {
43
+ verb: 'patch', path: '/api/v3/admin/orders/{order_id}/fulfillments/{id}/mark_delivered',
44
+ parameters: { 'fulfillment' => 'id' }
45
+ },
46
+ 'orders_cancel' => {
47
+ verb: 'patch', path: '/api/v3/admin/orders/{id}/cancel',
48
+ parameters: { 'order' => 'id', 'canceled_at' => nil, 'reason' => 'cancel_reason_id', 'note' => 'cancel_note' }
49
+ },
50
+ 'orders_complete' => {
51
+ verb: 'patch', path: '/api/v3/admin/orders/{id}/complete',
52
+ parameters: { 'order' => 'id' }
53
+ },
54
+ 'payments_capture' => {
55
+ verb: 'patch', path: '/api/v3/admin/orders/{order_id}/payments/{id}/capture',
56
+ parameters: { 'payment' => 'id' }
57
+ },
58
+ 'payments_void' => {
59
+ verb: 'patch', path: '/api/v3/admin/orders/{order_id}/payments/{id}/void',
60
+ parameters: { 'payment' => 'id' }
61
+ },
62
+ 'price_lists_activate' => {
63
+ verb: 'patch', path: '/api/v3/admin/price_lists/{id}/activate',
64
+ parameters: { 'price_list' => 'id' }
65
+ },
66
+ 'price_lists_deactivate' => {
67
+ verb: 'patch', path: '/api/v3/admin/price_lists/{id}/deactivate',
68
+ parameters: { 'price_list' => 'id' }
69
+ },
70
+ 'products_activate' => {
71
+ verb: 'patch', path: '/api/v3/admin/products/{id}/activate',
72
+ parameters: { 'product' => 'id' }
73
+ },
74
+ 'products_approve' => {
75
+ verb: 'patch', path: '/api/v3/admin/products/{id}/approve',
76
+ parameters: { 'product' => 'id' }
77
+ },
78
+ 'products_archive' => {
79
+ verb: 'patch', path: '/api/v3/admin/products/{id}/archive',
80
+ parameters: { 'product' => 'id' }
81
+ },
82
+ 'products_draft' => {
83
+ verb: 'patch', path: '/api/v3/admin/products/{id}/draft',
84
+ parameters: { 'product' => 'id' }
85
+ },
86
+ 'products_reject' => {
87
+ verb: 'patch', path: '/api/v3/admin/products/{id}/reject',
88
+ parameters: { 'product' => 'id' }
89
+ },
90
+ 'purchase_orders_cancel' => {
91
+ verb: 'patch', path: '/api/v3/admin/purchase_orders/{id}/cancel',
92
+ parameters: { 'purchase_order' => 'id' }
93
+ },
94
+ 'purchase_orders_close' => {
95
+ verb: 'patch', path: '/api/v3/admin/purchase_orders/{id}/close',
96
+ parameters: { 'purchase_order' => 'id' }
97
+ },
98
+ 'purchase_orders_mark_draft' => {
99
+ verb: 'patch', path: '/api/v3/admin/purchase_orders/{id}/mark_draft',
100
+ parameters: { 'purchase_order' => 'id' }
101
+ },
102
+ 'purchase_orders_mark_ordered' => {
103
+ verb: 'patch', path: '/api/v3/admin/purchase_orders/{id}/mark_ordered',
104
+ parameters: { 'purchase_order' => 'id' }
105
+ },
106
+ 'purchase_orders_receive' => {
107
+ verb: 'post', path: '/api/v3/admin/purchase_orders/{purchase_order_id}/stock_receipts',
108
+ parameters: { 'purchase_order' => 'purchase_order_id' }
109
+ },
110
+ 'refunds_create' => {
111
+ verb: 'post', path: '/api/v3/admin/orders/{order_id}/refunds',
112
+ parameters: {
113
+ 'payment' => 'payment_id', 'reason' => 'refund_reason_id', 'originator' => nil,
114
+ 'order' => 'order_id', 'tax_amount' => nil
115
+ }
116
+ },
117
+ 'seller_requirement_submissions_accept' => {
118
+ verb: 'patch', path: '/api/v3/admin/sellers/{seller_id}/requirement_submissions/{id}/accept',
119
+ parameters: { 'submission' => 'id' }
120
+ },
121
+ 'seller_requirement_submissions_reject' => {
122
+ verb: 'patch', path: '/api/v3/admin/sellers/{seller_id}/requirement_submissions/{id}/reject',
123
+ parameters: { 'submission' => 'id' }
124
+ },
125
+ 'seller_requirement_submissions_waive' => {
126
+ verb: 'post', path: '/api/v3/admin/sellers/{seller_id}/requirement_submissions',
127
+ parameters: { 'seller' => 'seller_id', 'requirement' => 'requirement_id' }
128
+ },
129
+ 'sellers_approve' => {
130
+ verb: 'patch', path: '/api/v3/admin/sellers/{id}/approve',
131
+ parameters: { 'seller' => 'id' }
132
+ },
133
+ 'sellers_invite' => {
134
+ verb: 'post', path: '/api/v3/admin/sellers/{id}/invite',
135
+ parameters: { 'seller' => 'id', 'inviter' => nil, 'role' => 'role_id' }
136
+ },
137
+ 'sellers_reject' => {
138
+ verb: 'patch', path: '/api/v3/admin/sellers/{id}/reject',
139
+ parameters: { 'seller' => 'id' }
140
+ },
141
+ 'sellers_reopen_onboarding' => {
142
+ verb: 'patch', path: '/api/v3/admin/sellers/{id}/reopen_onboarding',
143
+ parameters: { 'seller' => 'id' }
144
+ },
145
+ 'sellers_suspend' => {
146
+ verb: 'patch', path: '/api/v3/admin/sellers/{id}/suspend',
147
+ parameters: { 'seller' => 'id' }
148
+ },
149
+ 'shipping_labels_purchase' => {
150
+ verb: 'post', path: '/api/v3/admin/orders/{order_id}/fulfillments/{fulfillment_id}/labels',
151
+ parameters: { 'owner' => 'fulfillment_id' }
152
+ },
153
+ 'shipping_labels_refund' => {
154
+ verb: 'patch', path: '/api/v3/admin/orders/{order_id}/fulfillments/{fulfillment_id}/labels/{id}/refund',
155
+ parameters: { 'shipping_label' => 'id' }
156
+ },
157
+ 'stock_transfers_cancel' => {
158
+ verb: 'patch', path: '/api/v3/admin/stock_transfers/{id}/cancel',
159
+ parameters: { 'stock_transfer' => 'id' }
160
+ },
161
+ 'stock_transfers_close' => {
162
+ verb: 'patch', path: '/api/v3/admin/stock_transfers/{id}/close',
163
+ parameters: { 'stock_transfer' => 'id' }
164
+ },
165
+ 'stock_transfers_mark_draft' => {
166
+ verb: 'patch', path: '/api/v3/admin/stock_transfers/{id}/mark_draft',
167
+ parameters: { 'stock_transfer' => 'id' }
168
+ },
169
+ 'stock_transfers_mark_in_transit' => {
170
+ verb: 'patch', path: '/api/v3/admin/stock_transfers/{id}/mark_in_transit',
171
+ parameters: { 'stock_transfer' => 'id' }
172
+ },
173
+ 'stock_transfers_mark_ready' => {
174
+ verb: 'patch', path: '/api/v3/admin/stock_transfers/{id}/mark_ready',
175
+ parameters: { 'stock_transfer' => 'id' }
176
+ },
177
+ 'stock_transfers_receive' => {
178
+ verb: 'post', path: '/api/v3/admin/stock_transfers/{stock_transfer_id}/stock_receipts',
179
+ parameters: { 'stock_transfer' => 'stock_transfer_id' }
180
+ },
181
+ 'tax_exemption_certificates_verify' => {
182
+ verb: 'patch', path: '/api/v3/admin/companies/{company_id}/tax_exemption_certificates/{id}/verify',
183
+ parameters: { 'certificate' => 'id' }
184
+ },
185
+ 'variants_create' => {
186
+ verb: 'post', path: '/api/v3/admin/products/{product_id}/variants',
187
+ parameters: { 'product' => 'product_id', 'attributes' => :merge }
188
+ },
189
+ 'variants_update' => {
190
+ verb: 'patch', path: '/api/v3/admin/products/{product_id}/variants/{id}',
191
+ parameters: { 'variant' => 'id', 'attributes' => :merge }
192
+ }
193
+ }.freeze
194
+
195
+ class << self
196
+ # The 6.0.0 parameters of a tool that keeps its name, when it still
197
+ # runs the same operation.
198
+ #
199
+ # @param name [String]
200
+ # @param verb [String]
201
+ # @param path [String] the OpenAPI path
202
+ # @return [Hash{String => String, Symbol, nil}]
203
+ def parameters_for(name, verb, path)
204
+ tool = TOOLS[name.to_s]
205
+ return {} unless tool && tool[:verb] == verb.to_s && tool[:path] == path
206
+
207
+ tool[:parameters]
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,206 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Every Admin API operation an agent may call by name, and what it takes.
4
+ #
5
+ # A contract like {ResourceMap}: core declares the shape, and `spree_api`
6
+ # fills it from the Admin API's OpenAPI document — core's and every
7
+ # extension's — and the routes behind it. An operation that gains an
8
+ # endpoint is a tool in the same commit, with the summary, description and
9
+ # request body its rswag spec wrote.
10
+ #
11
+ # See docs/plans/6.0-agent-tools-from-openapi.md.
12
+ module OperationCatalog
13
+ # @!attribute name
14
+ # @return [String] the tool name, `orders_cancel`
15
+ # @!attribute verb
16
+ # @return [String] 'post', 'patch', 'put', 'delete' or 'get'
17
+ # @!attribute path
18
+ # @return [String] the OpenAPI path, with `{name}` segments
19
+ # @!attribute description
20
+ # @return [String] what the model reads when choosing the tool
21
+ # @!attribute summary
22
+ # @return [String] the operation summary, for the confirmation line
23
+ # @!attribute permission
24
+ # @return [String] the scope gating it, from the routed controller
25
+ # @!attribute resource_key
26
+ # @return [String, nil] the resource-map key of the routed controller's
27
+ # model, so a parent segment can be read off the record
28
+ # @!attribute path_params
29
+ # @return [Array<String>] the dynamic segments, in path order
30
+ # @!attribute body_properties
31
+ # @return [Hash{String => Hash}] the request body's JSON Schema
32
+ # properties, references inlined
33
+ # @!attribute body_required
34
+ # @return [Array<String>]
35
+ # @!attribute path_descriptions
36
+ # @return [Hash{String => String}]
37
+ # @!attribute listed
38
+ # @return [Boolean] false for a 6.0.0 name kept callable through 6.0.x
39
+ # @!attribute legacy_parameters
40
+ # @return [Hash{String => String, nil}] 6.0.0 parameter => its name now
41
+ Entry = Struct.new(:name, :verb, :path, :description, :summary, :permission, :resource_key,
42
+ :path_params, :body_properties, :body_required, :path_descriptions,
43
+ :listed, :legacy_parameters, keyword_init: true) do
44
+ # @return [Boolean] a read opted in to the catalog
45
+ def read?
46
+ verb == 'get'
47
+ end
48
+
49
+ # @return [Boolean] whether the call changes the store
50
+ def mutating
51
+ !read?
52
+ end
53
+
54
+ # The resource each `{x_id}` segment names, read off the path itself —
55
+ # `/orders/{order_id}/items` names `orders` — so a renamed nested
56
+ # route still points at the right resource.
57
+ #
58
+ # @return [Hash{String => String}] segment => resource key
59
+ def segment_keys
60
+ path.scan(%r{/([a-z_]+)/\{([a-z_]+_id)\}}).to_h { |key, segment| [segment, key] }
61
+ end
62
+
63
+ # The segments a call may leave out, read off the record a later
64
+ # segment names. The innermost is the record the call is about, never
65
+ # one to read off another.
66
+ #
67
+ # @return [Hash{String => String}] segment => resource key
68
+ def parent_keys
69
+ segment_keys.except(path_params.last)
70
+ end
71
+
72
+ # @param values [Hash{String => String}] path segment => value
73
+ # @return [String, nil] nil while a segment is unfilled
74
+ def path_for(values)
75
+ filled = path.gsub(/\{([a-z_]+)\}/) do
76
+ value = values[Regexp.last_match(1)]
77
+ value.present? ? ERB::Util.url_encode(value.to_s) : Regexp.last_match(0)
78
+ end
79
+ filled unless filled.include?('{')
80
+ end
81
+
82
+ # The tool's arguments as JSON Schema: the path segments and the
83
+ # request body, side by side.
84
+ #
85
+ # A segment or field a 6.0.0 name still reaches is left out of
86
+ # `required` through 6.0.x — a client validating against the schema
87
+ # would otherwise refuse the old name before the tool could map it —
88
+ # and so is a parent segment, which the tool can read off the record
89
+ # the call names. The tool checks both itself.
90
+ #
91
+ # @return [Hash]
92
+ def input_schema
93
+ properties = path_params.to_h do |segment|
94
+ [segment, { 'type' => 'string', 'description' => path_descriptions[segment] }.compact]
95
+ end.merge(body_properties)
96
+
97
+ aliased = legacy_parameters.values.grep(String)
98
+ parents = parent_keys.keys
99
+ # A 6.0.0 name no longer listed keeps the 6.0.0 contract, which
100
+ # required nothing the operation's body now does.
101
+ required = listed ? (path_params + body_required).uniq - aliased - parents : []
102
+
103
+ { 'type' => 'object', 'properties' => properties, 'required' => required }
104
+ end
105
+ end
106
+
107
+ class << self
108
+ # How the catalog fills itself the first time it is read. `spree_api`
109
+ # sets it to its walk over the document; an installation without the
110
+ # API engine has no operations to offer.
111
+ #
112
+ # @return [#call, nil]
113
+ attr_accessor :deriver
114
+
115
+ # @return [Array<Entry>] the operations offered by name
116
+ def all
117
+ derive!
118
+ entries.values
119
+ end
120
+
121
+ # The 6.0.0 names kept callable through 6.0.x, which no listing shows.
122
+ #
123
+ # @return [Array<Entry>]
124
+ def aliases
125
+ derive!
126
+ alias_entries.values
127
+ end
128
+
129
+ # A tool by its name, including a 6.0.0 name no longer listed.
130
+ #
131
+ # @param name [String]
132
+ # @return [Entry, nil]
133
+ def find(name)
134
+ derive!
135
+ entries[name.to_s] || alias_entries[name.to_s]
136
+ end
137
+
138
+ # Registers one operation. Re-registering a name replaces it, because
139
+ # the walk runs again after a code reload.
140
+ #
141
+ # @return [Entry]
142
+ def register(**attributes)
143
+ entry = build(listed: true, **attributes)
144
+ entries[entry.name] = entry
145
+ end
146
+
147
+ # Registers a 6.0.0 name for an operation, callable but not listed.
148
+ # A name an offered operation already has keeps that operation.
149
+ #
150
+ # @return [Entry, nil]
151
+ def register_alias(**attributes)
152
+ entry = build(**attributes, listed: false)
153
+ alias_entries[entry.name] = entry unless entries.key?(entry.name)
154
+ end
155
+
156
+ # @return [void]
157
+ def reset!
158
+ @entries = {}
159
+ @alias_entries = {}
160
+ end
161
+
162
+ # Marks the catalog for rebuilding on its next read.
163
+ #
164
+ # @return [void]
165
+ def stale!
166
+ @derived = false
167
+ end
168
+
169
+ # Fills the catalog once, under a mutex. The flag is set only once the
170
+ # deriver has finished, so a second reader waits on the mutex instead
171
+ # of reading a catalog the deriver has just emptied. Nothing the
172
+ # deriver calls reads the catalog back, so it cannot recurse.
173
+ #
174
+ # @return [void]
175
+ def derive!
176
+ return if @derived || deriver.nil?
177
+
178
+ derive_mutex.synchronize do
179
+ next if @derived
180
+
181
+ deriver.call
182
+ @derived = true
183
+ end
184
+ end
185
+
186
+ private
187
+
188
+ def build(**attributes)
189
+ Entry.new(legacy_parameters: {}, body_properties: {}, body_required: [], path_descriptions: {}, **attributes)
190
+ end
191
+
192
+ def entries
193
+ @entries ||= {}
194
+ end
195
+
196
+ def alias_entries
197
+ @alias_entries ||= {}
198
+ end
199
+
200
+ def derive_mutex
201
+ @derive_mutex ||= Mutex.new
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end