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.
- checksums.yaml +4 -4
- data/app/controllers/concerns/spree/api/v3/admin/embed_permissions.rb +5 -2
- data/app/controllers/spree/api/v3/admin/products_controller.rb +56 -0
- data/app/serializers/spree/api/v3/admin/admin_user_serializer.rb +2 -2
- data/app/serializers/spree/api/v3/admin/delivery_profile_serializer.rb +2 -1
- data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/import_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/invitation_serializer.rb +2 -2
- data/app/serializers/spree/api/v3/admin/price_rule_serializer.rb +3 -1
- data/app/serializers/spree/api/v3/admin/product_serializer.rb +8 -0
- data/app/serializers/spree/api/v3/admin/promotion_action_serializer.rb +2 -2
- data/app/serializers/spree/api/v3/admin/promotion_rule_serializer.rb +4 -1
- data/app/serializers/spree/api/v3/admin/promotion_serializer.rb +13 -0
- data/app/serializers/spree/api/v3/admin/return_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/seller_requirement_status_serializer.rb +3 -2
- data/app/serializers/spree/api/v3/admin/seller_serializer.rb +2 -2
- data/app/serializers/spree/api/v3/fulfillment_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/seller/import_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/seller/invitation_serializer.rb +2 -2
- data/app/serializers/spree/api/v3/seller/product_serializer.rb +8 -0
- data/app/serializers/spree/api/v3/seller/requirement_status_serializer.rb +3 -2
- data/config/initializers/typelizer.rb +8 -19
- data/config/routes.rb +5 -0
- data/lib/spree/api/admin_document.rb +284 -0
- data/lib/spree/api/agent_operations.rb +300 -0
- data/lib/spree/api/agent_resource_map.rb +32 -7
- data/lib/spree/api/agent_write_schemas.rb +15 -51
- data/lib/spree/api/engine.rb +14 -1
- data/lib/spree/api/enum_types.rb +4 -1
- data/lib/spree/api/openapi/schema_helper.rb +3 -3
- data/lib/spree/api/sdk_types.rb +190 -0
- data/lib/spree/api/sdk_types_task.rb +68 -0
- data/lib/spree/api/testing_support/agent_tools.rb +35 -0
- data/lib/spree/api/testing_support/agent_tools_contract.rb +110 -0
- data/lib/spree/api/testing_support/deterministic_openapi.rb +147 -0
- data/lib/spree/api/testing_support/filter_parameters.rb +99 -0
- data/lib/spree/api/testing_support/openapi.rb +232 -0
- data/lib/spree/api/testing_support/openapi_tasks.rb +21 -0
- data/lib/spree/api/typelizer_generation.rb +39 -11
- data/lib/tasks/openapi.rake +5 -0
- data/lib/tasks/sdk_types.rake +6 -0
- data/lib/typelizer/open_enums.rb +13 -1
- data/openapi/admin.yaml +1540 -33
- metadata +20 -9
- 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
|