spree_api 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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/spree/api/v3/admin/embed_permissions.rb +5 -2
  3. data/app/controllers/spree/api/v3/admin/products_controller.rb +56 -0
  4. data/app/serializers/spree/api/v3/admin/admin_user_serializer.rb +2 -2
  5. data/app/serializers/spree/api/v3/admin/delivery_profile_serializer.rb +2 -1
  6. data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +1 -1
  7. data/app/serializers/spree/api/v3/admin/import_serializer.rb +1 -1
  8. data/app/serializers/spree/api/v3/admin/invitation_serializer.rb +2 -2
  9. data/app/serializers/spree/api/v3/admin/price_rule_serializer.rb +3 -1
  10. data/app/serializers/spree/api/v3/admin/product_serializer.rb +8 -0
  11. data/app/serializers/spree/api/v3/admin/promotion_action_serializer.rb +2 -2
  12. data/app/serializers/spree/api/v3/admin/promotion_rule_serializer.rb +4 -1
  13. data/app/serializers/spree/api/v3/admin/promotion_serializer.rb +13 -0
  14. data/app/serializers/spree/api/v3/admin/return_serializer.rb +1 -1
  15. data/app/serializers/spree/api/v3/admin/seller_requirement_status_serializer.rb +3 -2
  16. data/app/serializers/spree/api/v3/admin/seller_serializer.rb +2 -2
  17. data/app/serializers/spree/api/v3/fulfillment_serializer.rb +1 -1
  18. data/app/serializers/spree/api/v3/seller/import_serializer.rb +1 -1
  19. data/app/serializers/spree/api/v3/seller/invitation_serializer.rb +2 -2
  20. data/app/serializers/spree/api/v3/seller/product_serializer.rb +8 -0
  21. data/app/serializers/spree/api/v3/seller/requirement_status_serializer.rb +3 -2
  22. data/config/initializers/typelizer.rb +8 -19
  23. data/config/routes.rb +5 -0
  24. data/lib/spree/api/admin_document.rb +284 -0
  25. data/lib/spree/api/agent_operations.rb +300 -0
  26. data/lib/spree/api/agent_resource_map.rb +32 -7
  27. data/lib/spree/api/agent_write_schemas.rb +15 -51
  28. data/lib/spree/api/engine.rb +14 -1
  29. data/lib/spree/api/enum_types.rb +4 -1
  30. data/lib/spree/api/openapi/schema_helper.rb +3 -3
  31. data/lib/spree/api/sdk_types.rb +190 -0
  32. data/lib/spree/api/sdk_types_task.rb +68 -0
  33. data/lib/spree/api/testing_support/agent_tools.rb +35 -0
  34. data/lib/spree/api/testing_support/agent_tools_contract.rb +110 -0
  35. data/lib/spree/api/testing_support/deterministic_openapi.rb +147 -0
  36. data/lib/spree/api/testing_support/filter_parameters.rb +99 -0
  37. data/lib/spree/api/testing_support/openapi.rb +232 -0
  38. data/lib/spree/api/testing_support/openapi_tasks.rb +21 -0
  39. data/lib/spree/api/typelizer_generation.rb +39 -11
  40. data/lib/tasks/openapi.rake +5 -0
  41. data/lib/tasks/sdk_types.rake +6 -0
  42. data/lib/typelizer/open_enums.rb +13 -1
  43. data/openapi/admin.yaml +1540 -33
  44. metadata +20 -9
  45. data/lib/spree/api/agent_workflow_endpoints.rb +0 -113
@@ -0,0 +1,284 @@
1
+ module Spree
2
+ module Api
3
+ # The Admin API's OpenAPI document as agents read it: core's `admin.yaml`
4
+ # merged with the `openapi/admin.yaml` each extension ships in its gem.
5
+ #
6
+ # An extension documents its admin endpoints the way core does — rswag
7
+ # integration specs, generated into a file — so its operations reach
8
+ # agents, API docs and SDKs from the same contract. Nothing registers the
9
+ # file: an engine that has one is read.
10
+ #
11
+ # Loaded once, lazily: core's document alone is well over a megabyte, and
12
+ # an installation with no agent client should never pay for parsing it.
13
+ #
14
+ # See docs/plans/6.0-agent-tools-from-openapi.md.
15
+ module AdminDocument
16
+ FILTER_TABLES = 'x-spree-filter-tables'.freeze
17
+
18
+ # Where an extension keeps its generated document, relative to its root.
19
+ EXTENSION_PATH = File.join('openapi', 'admin.yaml')
20
+
21
+ HTTP_VERBS = %w[get post put patch delete head options].freeze
22
+
23
+ class << self
24
+ # @return [Hash] the merged document
25
+ def document
26
+ @document ||= load_document
27
+ end
28
+
29
+ # Operations or components two documents describe differently. The
30
+ # first document keeps them — core's, then extensions in load order —
31
+ # and the later one's are left out rather than silently replacing an
32
+ # endpoint or failing every tool; the contract spec fails on any.
33
+ #
34
+ # @return [Array<String>]
35
+ def conflicts
36
+ document
37
+ @conflicts
38
+ end
39
+
40
+ # Which document an operation came from, so a contract spec can check
41
+ # an extension against its own operations only.
42
+ #
43
+ # @param path [String] the OpenAPI path, e.g. `/api/v3/admin/quotes/{id}/send`
44
+ # @param verb [String, Symbol]
45
+ # @return [String, nil] the engine's name, or `spree_api` for core
46
+ def source_of(path, verb)
47
+ document
48
+ @sources[[path, verb.to_s.downcase]]
49
+ end
50
+
51
+ # Forgets the parsed document only when a document file changed, so a
52
+ # code reload in development does not re-parse a megabyte of generated
53
+ # YAML that no edit to Ruby could have changed.
54
+ #
55
+ # @return [void]
56
+ def refresh!
57
+ current = signature
58
+ reset! unless current == @signature
59
+ @signature = current
60
+ end
61
+
62
+ # Forgets the parsed document, so the next reader loads it again.
63
+ #
64
+ # @return [void]
65
+ def reset!
66
+ @document = nil
67
+ @sources = nil
68
+ @conflicts = nil
69
+ end
70
+
71
+ # Core's generated document: `docs/` in a source checkout, or the copy
72
+ # the release packs into the gem. Resolved lazily: the engine class
73
+ # does not exist yet while this file loads.
74
+ #
75
+ # @return [Pathname]
76
+ def core_path
77
+ source = Spree::Api::Engine.root.join('..', '..', 'docs', 'api-reference', 'admin.yaml')
78
+ source.exist? ? source : Spree::Api::Engine.root.join(EXTENSION_PATH)
79
+ end
80
+
81
+ # Every other engine that ships a document, and the application
82
+ # itself, which may add admin endpoints of its own.
83
+ #
84
+ # @return [Hash{String => Pathname}] engine name => document
85
+ def extension_paths
86
+ engines = Rails.application.railties.grep(Rails::Engine) + [Rails.application]
87
+ engines.each_with_object({}) do |engine, paths|
88
+ next if engine.is_a?(Spree::Api::Engine)
89
+
90
+ path = engine.root&.join(EXTENSION_PATH)
91
+ next unless path&.exist?
92
+ next if path.expand_path == core_path.expand_path
93
+
94
+ paths[engine.engine_name] = path
95
+ end
96
+ end
97
+
98
+ # A request body is declared once per endpoint and published as a
99
+ # component, so the operation carries a `$ref` to it rather than the
100
+ # properties themselves. A body that builds on a shared shape is an
101
+ # `allOf` of that shape and its own fields, read as one object.
102
+ # Followed a few hops, which is as deep as the generated document
103
+ # nests them.
104
+ #
105
+ # @param schema [Hash, nil] a schema that may be a reference
106
+ # @return [Hash, nil] the schema it names
107
+ def resolve_schema(schema, depth = 0)
108
+ return unless schema.is_a?(Hash)
109
+ return if depth > 3
110
+ return merge_all_of(schema['allOf'], depth) if schema.key?('allOf')
111
+ return schema unless schema.key?('$ref')
112
+
113
+ resolve_schema(component(schema['$ref']), depth + 1)
114
+ end
115
+
116
+ # The schema with every reference replaced by what it names, all the
117
+ # way down, so it can be handed to a client that has no document to
118
+ # look `$ref`s up in. A reference that leads back to itself stops at
119
+ # an untyped object rather than recursing.
120
+ #
121
+ # @param schema [Object]
122
+ # @return [Object]
123
+ def inline(schema, seen = [])
124
+ case schema
125
+ when Array
126
+ schema.map { |item| inline(item, seen) }
127
+ when Hash
128
+ return inline_reference(schema, seen) if schema.key?('$ref')
129
+ return inline(merge_all_of(schema['allOf'], 0), seen) if schema.key?('allOf')
130
+
131
+ schema.transform_values { |value| inline(value, seen) }
132
+ else
133
+ schema
134
+ end
135
+ end
136
+
137
+ # A parameter, followed through a `$ref` to `components/parameters`.
138
+ #
139
+ # @param parameter [Hash]
140
+ # @return [Hash, nil]
141
+ def resolve_parameter(parameter)
142
+ return parameter unless parameter.is_a?(Hash) && parameter.key?('$ref')
143
+
144
+ document.dig('components', 'parameters', parameter['$ref'].to_s.split('/').last)
145
+ end
146
+
147
+ private
148
+
149
+ def signature
150
+ ([core_path] + extension_paths.values).map { |path| [path.to_s, (File.mtime(path) if File.exist?(path))] }
151
+ end
152
+
153
+ def inline_reference(schema, seen)
154
+ reference = schema['$ref']
155
+ return { 'type' => 'object' } if seen.include?(reference)
156
+
157
+ target = component(reference)
158
+ target ? inline(target, seen + [reference]) : { 'type' => 'object' }
159
+ end
160
+
161
+ def component(reference)
162
+ name = reference.to_s.split('/').last
163
+ document.dig('components', 'schemas', name)
164
+ end
165
+
166
+ def merge_all_of(parts, depth)
167
+ parts.filter_map { |part| resolve_schema(part, depth + 1) }.each_with_object({}) do |part, merged|
168
+ merged['properties'] = (merged['properties'] || {}).merge(part['properties'] || {})
169
+ merged['required'] = (merged['required'] || []) | Array(part['required'])
170
+ end
171
+ end
172
+
173
+ def load_document
174
+ @sources = {}
175
+ @conflicts = []
176
+ merged = read(core_path)
177
+ record_sources(merged, 'spree_api')
178
+ @core_filter_tables = merged.dig('components', FILTER_TABLES).to_h.keys.to_set
179
+
180
+ extension_paths.each do |engine, path|
181
+ merge!(merged, read(path), engine)
182
+ end
183
+
184
+ merged
185
+ end
186
+
187
+ # A document that is missing or does not parse is not a boot failure:
188
+ # its operations are simply not offered, which is the safe direction.
189
+ def read(path)
190
+ return {} unless File.exist?(path)
191
+
192
+ document = YAML.unsafe_load_file(path)
193
+ return document if valid?(document)
194
+
195
+ Rails.logger.warn("[Spree] the admin API document #{path} is not an OpenAPI document; it is ignored")
196
+ {}
197
+ rescue StandardError => e
198
+ Rails.logger.warn("[Spree] could not read the admin API document #{path}: #{e.class}: #{e.message}")
199
+ {}
200
+ end
201
+
202
+ # An OpenAPI document whose operations are objects: anything else
203
+ # would raise in a reader that digs into it, on every agent request.
204
+ def valid?(document)
205
+ document.is_a?(Hash) && document.key?('openapi') &&
206
+ Array(document['paths']).all? { |path, item| path.is_a?(String) && valid_path_item?(item) } &&
207
+ (document['components'].nil? || document['components'].is_a?(Hash))
208
+ end
209
+
210
+ def valid_path_item?(item)
211
+ item.is_a?(Hash) && item.all? do |key, operation|
212
+ next true unless HTTP_VERBS.include?(key)
213
+
214
+ operation.is_a?(Hash) && valid_request_body?(operation['requestBody'])
215
+ end
216
+ end
217
+
218
+ # The catalog digs `content`, `application/json` and `schema`, so each
219
+ # is an object where present.
220
+ def valid_request_body?(body)
221
+ return true if body.nil?
222
+ return false unless body.is_a?(Hash)
223
+ return true if body['content'].nil?
224
+ return false unless body['content'].is_a?(Hash)
225
+
226
+ json = body['content']['application/json']
227
+ json.nil? || (json.is_a?(Hash) && (json['schema'].nil? || json['schema'].is_a?(Hash)))
228
+ end
229
+
230
+ def conflict(message)
231
+ @conflicts << message
232
+ Rails.logger.error("[Spree] admin API documents conflict: #{message}")
233
+ end
234
+
235
+ def record_sources(document, engine)
236
+ Array(document['paths']).each do |path, operations|
237
+ operations.each_key { |verb| @sources[[path, verb.to_s]] = engine if HTTP_VERBS.include?(verb.to_s) }
238
+ end
239
+ end
240
+
241
+ def merge!(merged, addition, engine)
242
+ merged['paths'] ||= {}
243
+ Array(addition['paths']).each do |path, operations|
244
+ existing = merged['paths'][path] ||= {}
245
+ operations.each do |key, value|
246
+ # Path-level keys (`parameters`) describe the path, not an
247
+ # operation, and two documents for one path share them.
248
+ next existing[key] ||= value unless HTTP_VERBS.include?(key)
249
+
250
+ if existing.key?(key)
251
+ conflict("#{key.upcase} #{path} is documented by both #{@sources[[path, key]]} and #{engine}")
252
+ next
253
+ end
254
+
255
+ existing[key] = value
256
+ @sources[[path, key]] = engine
257
+ end
258
+ end
259
+
260
+ components = merged['components'] ||= {}
261
+ Array(addition['components']).each do |section, definitions|
262
+ next unless definitions.is_a?(Hash)
263
+
264
+ existing = components[section] ||= {}
265
+ definitions.each do |name, definition|
266
+ # An extension's document carries the filter tables of the core
267
+ # records its filters reach, as the extension's own app filters
268
+ # them, so its SDK types are complete. Another gem there may
269
+ # change one (an extra scope); core's definition stands.
270
+ next if section == FILTER_TABLES && @core_filter_tables.include?(name)
271
+
272
+ if existing.key?(name) && existing[name] != definition
273
+ conflict("the #{name} #{section.singularize} is defined differently by #{engine} and an earlier document")
274
+ next
275
+ end
276
+
277
+ existing[name] = definition
278
+ end
279
+ end
280
+ end
281
+ end
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,300 @@
1
+ module Spree
2
+ module Api
3
+ # Fills {Spree::AgentTools::OperationCatalog} from the merged Admin API
4
+ # document ({AdminDocument}) and the routes behind it.
5
+ #
6
+ # Every write operation that is not a resource's plain create, update or
7
+ # destroy becomes a tool — cancel, capture, approve, send — unless its
8
+ # spec opts it out with a reason. CRUD stays with the generic tools, and a
9
+ # read becomes a tool only when its spec opts it in. Nothing here is a
10
+ # list of tools: an endpoint that gains an rswag spec is a tool in the
11
+ # same commit, core's or an extension's.
12
+ #
13
+ # See docs/plans/6.0-agent-tools-from-openapi.md.
14
+ module AgentOperations
15
+ WRITE_VERBS = %w[post patch put delete].freeze
16
+ CRUD_ACTIONS = %w[index show create update destroy].freeze
17
+ ADMIN_PREFIX = '/api/v3/admin/'.freeze
18
+
19
+ # The opt-out and opt-in marks an rswag spec sets (see the `agent_tool`
20
+ # spec helper), and the reason an opt-out must carry.
21
+ AGENT_KEY = 'x-spree-agent'.freeze
22
+ REASON_KEY = 'x-spree-agent-reason'.freeze
23
+
24
+ # The line `admin_scope` appends to a description. It is for the
25
+ # rendered docs; a tool is already offered only to a key holding it.
26
+ SCOPE_NOTE = /^\*\*Required scope:\*\*.*$/
27
+
28
+ class << self
29
+ # Registers this walk as how the catalog fills itself, and marks it
30
+ # stale so the next reader gets a fresh one.
31
+ #
32
+ # @return [void]
33
+ def stale!
34
+ Spree::AgentTools::OperationCatalog.deriver = -> { install }
35
+ Spree::AgentTools::OperationCatalog.stale!
36
+ Spree::AgentTools::OperationTool.clear_cache!
37
+ AdminDocument.refresh!
38
+ AgentWriteSchemas.reset!
39
+ end
40
+
41
+ # @param catalog [Module] defaults to the core catalog
42
+ # @return [Integer] how many operations were registered
43
+ def install(catalog: Spree::AgentTools::OperationCatalog)
44
+ catalog.reset!
45
+ @models = {}
46
+
47
+ by_route = candidates.index_by { |candidate| [candidate[:verb], candidate[:path]] }
48
+ included = by_route.values.select { |candidate| disposition(candidate) == :included }
49
+ listed = resolve_collisions(included.filter_map { |candidate| safe_entry_for(candidate) })
50
+ listed.each { |entry| catalog.register(**entry) }
51
+ register_aliases(catalog, by_route, listed.to_set { |entry| entry[:name] })
52
+
53
+ listed.size
54
+ end
55
+
56
+ # Every admin operation in the document, with the route and
57
+ # controller it resolves to. Public for the contract spec, which
58
+ # reports the operations that resolve to nothing.
59
+ #
60
+ # @return [Array<Hash>]
61
+ def candidates
62
+ routes = route_index
63
+
64
+ Array(AdminDocument.document['paths']).flat_map do |path, operations|
65
+ next [] unless path.is_a?(String) && path.start_with?(ADMIN_PREFIX) && operations.is_a?(Hash)
66
+
67
+ operations.filter_map do |verb, operation|
68
+ next unless operation.is_a?(Hash) && (WRITE_VERBS + ['get']).include?(verb)
69
+
70
+ { path: path, verb: verb, operation: operation, route: routes[[verb, path]],
71
+ path_parameters: Array(operations['parameters']) }
72
+ end
73
+ end
74
+ end
75
+
76
+ # Whether an operation becomes a listed tool, and if not, why.
77
+ #
78
+ # @param candidate [Hash]
79
+ # @return [Symbol] :included, :crud, :read, :opted_out, :unrouted,
80
+ # :unscoped or :withheld
81
+ def disposition(candidate)
82
+ return :unrouted if candidate[:route].nil?
83
+
84
+ controller = candidate[:route][:controller]
85
+ return :unscoped if controller.nil? || scope_of(controller).blank?
86
+ return :withheld if withheld?(controller)
87
+
88
+ flag = candidate[:operation][AGENT_KEY]
89
+ return :opted_out if flag == false
90
+
91
+ if candidate[:verb] == 'get'
92
+ flag == true ? :included : :read
93
+ elsif CRUD_ACTIONS.include?(candidate[:route][:action])
94
+ # A create that is really an action — receiving stock against a
95
+ # transfer — names its tool explicitly, and is then offered as one.
96
+ candidate[:operation]['operationId'].present? ? :included : :crud
97
+ else
98
+ :included
99
+ end
100
+ end
101
+
102
+ # Every admin route, with its path in OpenAPI form, from the engine's
103
+ # routes — where an extension's admin routes are drawn too. Public for
104
+ # the contract spec, which checks each against the document.
105
+ #
106
+ # @return [Array<Hash>] `{verb:, path:, controller:, action:}`
107
+ def admin_routes
108
+ Spree::Core::Engine.routes.routes.flat_map do |route|
109
+ controller_path = route.defaults[:controller].to_s
110
+ next [] unless controller_path.start_with?('spree/api/v3/admin')
111
+
112
+ path = route.path.spec.to_s.sub('(.:format)', '').gsub(/:([a-z_]+)/, '{\1}')
113
+ route.verb.to_s.split('|').map do |verb|
114
+ { verb: verb.downcase, path: path, controller: controller_class(controller_path),
115
+ action: route.defaults[:action].to_s }
116
+ end
117
+ end
118
+ end
119
+
120
+ # The tool name an operation gets: its `operationId`, or the resource
121
+ # and the action.
122
+ #
123
+ # @return [String]
124
+ def tool_name(candidate)
125
+ candidate[:operation]['operationId'].presence ||
126
+ "#{resource_key(candidate[:route][:controller])}_#{candidate[:route][:action]}"
127
+ end
128
+
129
+ private
130
+
131
+ # One operation the catalog cannot read is left out and logged, rather
132
+ # than taking every other tool down with it: validating a document's
133
+ # shape up front cannot foresee every way a generated file goes wrong.
134
+ def safe_entry_for(candidate)
135
+ entry_for(candidate)
136
+ rescue StandardError => e
137
+ Rails.logger.error("[Spree] agent tool for #{candidate[:verb].upcase} #{candidate[:path]} left out: " \
138
+ "#{e.class}: #{e.message}")
139
+ nil
140
+ end
141
+
142
+ def entry_for(candidate)
143
+ operation = candidate[:operation]
144
+ controller = candidate[:route][:controller]
145
+ name = tool_name(candidate)
146
+ read = candidate[:verb] == 'get'
147
+ # A read takes its arguments as query parameters, a write as its body.
148
+ body = read ? query_schema(Array(candidate[:path_parameters]) + Array(operation['parameters'])) : request_body(operation)
149
+
150
+ {
151
+ name: name,
152
+ verb: candidate[:verb],
153
+ path: candidate[:path],
154
+ summary: operation['summary'].to_s,
155
+ description: description_for(operation),
156
+ permission: "#{scope_kind(controller, candidate[:route][:action])}_#{scope_of(controller)}",
157
+ resource_key: resource_key(controller),
158
+ path_params: candidate[:path].scan(/\{([a-z_]+)\}/).flatten,
159
+ path_descriptions: path_descriptions(candidate[:path_parameters] + Array(operation['parameters'])),
160
+ body_properties: body.fetch('properties', {}),
161
+ body_required: Array(body['required']),
162
+ legacy_parameters: Spree::AgentTools::LegacyTools.parameters_for(name, candidate[:verb], candidate[:path])
163
+ }
164
+ end
165
+
166
+ # A 6.0.0 tool whose name is no longer derived — it pointed at a
167
+ # create, or carried its workflow's name — stays callable through
168
+ # 6.0.x, unlisted, under that name.
169
+ def register_aliases(catalog, by_route, listed_names)
170
+ # The catalog is still being filled, so the names already offered
171
+ # are passed in rather than read back from it.
172
+ Spree::AgentTools::LegacyTools::TOOLS.each do |old, tool|
173
+ next if listed_names.include?(old)
174
+
175
+ candidate = by_route[[tool[:verb], tool[:path]]]
176
+ next unless candidate && %i[included crud].include?(disposition(candidate))
177
+
178
+ entry = safe_entry_for(candidate)
179
+ catalog.register_alias(**entry, name: old, legacy_parameters: tool[:parameters]) if entry
180
+ end
181
+ end
182
+
183
+ # Two operations deriving one name: the shallower path keeps it, and
184
+ # the contract spec fails so the other gets an `operationId`.
185
+ def resolve_collisions(entries)
186
+ entries.group_by { |entry| entry[:name] }.map do |name, claimants|
187
+ next claimants.first if claimants.one?
188
+
189
+ kept = claimants.min_by { |entry| [entry[:path].count('{'), entry[:path].length] }
190
+ Rails.logger.warn("[Spree] agent tool #{name} is derived by #{claimants.size} operations; " \
191
+ "#{kept[:verb].upcase} #{kept[:path]} keeps it")
192
+ kept
193
+ end
194
+ end
195
+
196
+ # The summary, then the description's opening paragraph: what the
197
+ # operation does, in the words its spec already reviews.
198
+ def description_for(operation)
199
+ prose = operation['description'].to_s.gsub(SCOPE_NOTE, '').strip
200
+ paragraph = prose.split(/\n\s*\n/).first.to_s.squish
201
+
202
+ [operation['summary'].to_s.strip.delete_suffix('.'), paragraph].compact_blank.join('. ')
203
+ end
204
+
205
+ # Path-level parameters first, so an operation's own description of a
206
+ # segment wins.
207
+ def path_descriptions(parameters)
208
+ parameters.map { |parameter| AdminDocument.resolve_parameter(parameter) }.each_with_object({}) do |parameter, result|
209
+ next unless parameter.is_a?(Hash) && parameter['in'] == 'path'
210
+
211
+ result[parameter['name']] = parameter['description'] if parameter['description'].present?
212
+ end
213
+ end
214
+
215
+ # Path-level parameters first, so an operation's own one of the same
216
+ # name wins.
217
+ def query_schema(parameters)
218
+ parameters = parameters.map { |parameter| AdminDocument.resolve_parameter(parameter) }.
219
+ select { |parameter| parameter.is_a?(Hash) && parameter['in'] == 'query' }.
220
+ reverse.uniq { |parameter| parameter['name'] }.reverse
221
+ properties = parameters.to_h do |parameter|
222
+ [parameter['name'], json_schema(AdminDocument.inline(parameter['schema'] || {}).merge(
223
+ { 'description' => parameter['description'] }.compact
224
+ ))]
225
+ end
226
+
227
+ { 'properties' => properties, 'required' => parameters.select { |p| p['required'] }.map { |p| p['name'] } }
228
+ end
229
+
230
+ def request_body(operation)
231
+ content = operation['requestBody'].is_a?(Hash) ? operation['requestBody']['content'] : nil
232
+ schema = content.is_a?(Hash) ? content.dig('application/json', 'schema') : nil
233
+ schema ? json_schema(AdminDocument.inline(schema)) : {}
234
+ end
235
+
236
+ # OpenAPI 3.0 marks a nullable field with `nullable: true`; JSON
237
+ # Schema, which a client validates arguments against, says it with a
238
+ # type list.
239
+ def json_schema(schema)
240
+ case schema
241
+ when Array then schema.map { |item| json_schema(item) }
242
+ when Hash
243
+ converted = schema.except('nullable').transform_values { |value| json_schema(value) }
244
+ if schema['nullable'] == true && converted['type'].is_a?(String)
245
+ converted['type'] = [converted['type'], 'null']
246
+ end
247
+ converted
248
+ else schema
249
+ end
250
+ end
251
+
252
+ # [verb, OpenAPI path] => the action behind it.
253
+ def route_index
254
+ admin_routes.each_with_object({}) do |route, index|
255
+ index[[route[:verb], route[:path]]] ||= route.slice(:controller, :action)
256
+ end
257
+ end
258
+
259
+ def controller_class(controller_path)
260
+ "#{controller_path}_controller".camelize.safe_constantize
261
+ end
262
+
263
+ def scope_of(controller)
264
+ controller.try(:_scoped_resource)
265
+ end
266
+
267
+ # The scope kind the endpoint itself demands for an action — `read`
268
+ # for the controller's read actions, `write` otherwise — so a tool is
269
+ # offered to exactly the keys the endpoint will let through.
270
+ def scope_kind(controller, action)
271
+ instance = controller.allocate
272
+ reads = instance.respond_to?(:read_actions, true) ? instance.send(:read_actions) : %w[index show]
273
+ Array(reads).map(&:to_s).include?(action) ? 'read' : 'write'
274
+ rescue StandardError, NotImplementedError
275
+ 'write'
276
+ end
277
+
278
+ # Controllers whose records carry a live credential — the ones the
279
+ # resource map withholds — offer no tools either: an action on an API
280
+ # key or a webhook endpoint answers with the secret.
281
+ def withheld?(controller)
282
+ model = model_for(controller)
283
+ model && AgentResourceMap::WITHHELD_MODELS.include?(model.name)
284
+ end
285
+
286
+ # Keyed the way the resource map keys the model, so a tool's
287
+ # resource_key finds its entry there.
288
+ def resource_key(controller)
289
+ model = model_for(controller)
290
+ model ? AgentResourceMap.key_for(model) : controller.controller_name
291
+ end
292
+
293
+ # Once per controller per derivation: every operation it serves asks.
294
+ def model_for(controller)
295
+ (@models ||= {}).fetch(controller) { @models[controller] = AgentResourceMap.model_for(controller) }
296
+ end
297
+ end
298
+ end
299
+ end
300
+ end
@@ -142,6 +142,15 @@ module Spree
142
142
  transform_values { |pairs| pairs.map(&:last).min_by { |path| [path.count(':'), path.length] } }
143
143
  end
144
144
 
145
+ # The controller served at the shallowest path, which is where a tool
146
+ # dispatches: for a record it is the show path, else the collection.
147
+ def shallowest_candidate(candidates)
148
+ candidates.min_by do |entry|
149
+ path = Array(entry[:api_paths]).to_h.values_at(:show, :index).compact.first.to_s
150
+ [path.empty? ? Float::INFINITY : path.count(':'), path.length]
151
+ end
152
+ end
153
+
145
154
  def api_paths_for(controller)
146
155
  candidates = Spree::Core::Engine.routes.routes.filter_map do |route|
147
156
  next unless route.defaults[:controller] == controller.controller_path
@@ -190,6 +199,24 @@ module Spree
190
199
  }
191
200
  end
192
201
 
202
+ # The plural the resource is addressed by, taken from the model so two
203
+ # controllers serving the same model (a nested one and a top-level one)
204
+ # agree on it. The operation catalog keys its tools the same way.
205
+ #
206
+ # @param model [Class]
207
+ # @return [String]
208
+ def key_for(model)
209
+ model.model_name.element.pluralize
210
+ end
211
+
212
+ # The model a controller serves, or nil for one that serves none.
213
+ #
214
+ # @param controller [Class]
215
+ # @return [Class, nil]
216
+ def model_for(controller)
217
+ safely(controller.allocate, :model_class)
218
+ end
219
+
193
220
  private
194
221
 
195
222
  # What the controller does with its own collection. A controller that
@@ -228,6 +255,11 @@ module Spree
228
255
  # withdrawn, and only when the controllers disagree about the
229
256
  # scope guarding it.
230
257
  narrowest.merge(
258
+ # The serializer of the controller the tools actually reach:
259
+ # the one whose path won below. `narrowest` is chosen on
260
+ # permission, which left `products` described by the catalog
261
+ # membership serializer and `expand: ["variants"]` refused.
262
+ serializer_name: shallowest_candidate(candidates)[:serializer_name],
231
263
  create_workflow_key: candidates.filter_map { |entry| entry[:create_workflow_key] }.first,
232
264
  update_workflow_key: candidates.filter_map { |entry| entry[:update_workflow_key] }.first,
233
265
  write_permission: contested ? nil : narrowest[:write_permission],
@@ -299,13 +331,6 @@ module Spree
299
331
  false
300
332
  end
301
333
 
302
- # The plural the resource is addressed by, taken from the model so two
303
- # controllers serving the same model (a nested one and a top-level one)
304
- # agree on it.
305
- def key_for(model)
306
- model.model_name.element.pluralize
307
- end
308
-
309
334
  # `Spree::Base.for_store` falls back to the bare class for a model with
310
335
  # no Store association, which would hand this store's agent every
311
336
  # store's rows. A model that defines its own `for_store` has made a