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
@@ -11,8 +11,8 @@ module Spree
11
11
  # fill a form in correctly. Reading it means the generic write tools offer
12
12
  # exactly the documented endpoint and nothing else.
13
13
  #
14
- # Loaded once, lazily: the document is well over a megabyte, and an
15
- # installation with no agent client should never pay for parsing it.
14
+ # Read from {AdminDocument}, so an extension's documented resources
15
+ # count the same as core's.
16
16
  module AgentWriteSchemas
17
17
  # A collection endpoint under the admin namespace: `/api/v3/admin/markets`
18
18
  # but not `/api/v3/admin/markets/{id}` or a nested child.
@@ -33,29 +33,25 @@ module Spree
33
33
  self.for(key).keys
34
34
  end
35
35
 
36
- # Where the generated document lives: `docs/` in a source checkout,
37
- # or the copy the release packs into the gem. Resolved lazily: the
38
- # engine class does not exist yet while this file loads.
36
+ # Core's generated document, which the merged one starts from.
39
37
  #
40
38
  # @return [Pathname]
41
39
  def spec_path
42
- source = Spree::Api::Engine.root.join('..', '..', 'docs', 'api-reference', 'admin.yaml')
43
- source.exist? ? source : Spree::Api::Engine.root.join('openapi', 'admin.yaml')
40
+ AdminDocument.core_path
44
41
  end
45
42
 
46
- # The parsed document, for a reader that needs more of it than the
47
- # write bodies — the workflow endpoint map wants the member actions'
48
- # bodies too, and parsing a megabyte of YAML twice is wasteful.
43
+ # The merged document — core's and every extension's — so an
44
+ # extension's resources are generically writable the same way core's
45
+ # are.
49
46
  #
50
47
  # @return [Hash]
51
48
  def document
52
- @document ||= begin
53
- path = spec_path
54
- File.exist?(path) ? (YAML.unsafe_load_file(path) || {}) : {}
55
- rescue StandardError => e
56
- Rails.logger.warn("[Spree] could not read the admin API document: #{e.class}: #{e.message}")
57
- {}
58
- end
49
+ AdminDocument.document
50
+ end
51
+
52
+ # @return [void]
53
+ def reset!
54
+ @schemas = nil
59
55
  end
60
56
 
61
57
  private
@@ -64,17 +60,8 @@ module Spree
64
60
  @schemas ||= load_schemas
65
61
  end
66
62
 
67
- # A missing or unparseable document is not a boot failure: the generic
68
- # write tools simply offer nothing, which is the safe direction.
69
63
  def load_schemas
70
- path = spec_path
71
- return {} unless File.exist?(path)
72
-
73
- document = YAML.unsafe_load_file(path)
74
64
  extract(document)
75
- rescue StandardError => e
76
- Rails.logger.warn("[Spree] could not read admin API write schemas: #{e.class}: #{e.message}")
77
- {}
78
65
  end
79
66
 
80
67
  def extract(document)
@@ -98,32 +85,9 @@ module Spree
98
85
 
99
86
  public
100
87
 
101
- # A request body is declared once per endpoint and published as a
102
- # component, so the operation carries a `$ref` to it rather than the
103
- # properties themselves. A body that builds on a shared shape is an
104
- # `allOf` of that shape and its own fields, read as one object.
105
- # Followed a few hops, which is as deep as the generated document
106
- # nests them.
107
- #
108
- # @param schema [Hash, nil] a schema that may be a reference
109
- # @return [Hash, nil] the schema it names
88
+ # @see AdminDocument.resolve_schema
110
89
  def resolve_schema(schema, depth = 0)
111
- return unless schema.is_a?(Hash)
112
- return if depth > 3
113
- return merge_all_of(schema['allOf'], depth) if schema.key?('allOf')
114
- return schema unless schema.key?('$ref')
115
-
116
- name = schema['$ref'].to_s.split('/').last
117
- resolve_schema(document.dig('components', 'schemas', name), depth + 1)
118
- end
119
-
120
- private
121
-
122
- def merge_all_of(parts, depth)
123
- parts.filter_map { |part| resolve_schema(part, depth + 1) }.each_with_object({}) do |part, merged|
124
- merged['properties'] = (merged['properties'] || {}).merge(part['properties'] || {})
125
- merged['required'] = (merged['required'] || []) | Array(part['required'])
126
- end
90
+ AdminDocument.resolve_schema(schema, depth)
127
91
  end
128
92
  end
129
93
  end
@@ -2,10 +2,11 @@ require 'rails/engine'
2
2
 
3
3
  require_relative 'dependencies'
4
4
  require_relative 'configuration'
5
+ require_relative 'admin_document'
5
6
  require_relative 'agent_write_schemas'
6
7
  require_relative 'agent_resource_map'
7
8
  require_relative 'agent_membership_map'
8
- require_relative 'agent_workflow_endpoints'
9
+ require_relative 'agent_operations'
9
10
  require_relative 'oauth'
10
11
 
11
12
  module Spree
@@ -14,6 +15,17 @@ module Spree
14
15
  isolate_namespace Spree
15
16
  engine_name 'spree_api'
16
17
 
18
+ # Typelizer 0.13+ ties two things to TYPELIZER: recording the serializers'
19
+ # `typelize` declarations, and generating types on a development server's
20
+ # first request. The rswag specs build the OpenAPI schemas from those
21
+ # declarations, so tests record them; an app's server must never generate,
22
+ # or it would write Spree's SDK types into the installed gem.
23
+ # `rake typelizer:generate` sets TYPELIZER=true itself. Set before
24
+ # Typelizer reads it when configuring its DSL.
25
+ initializer 'spree.api.typelizer', before: 'typelizer.configure_dsl' do
26
+ ENV['TYPELIZER'] ||= Rails.env.test? ? 'true' : 'false'
27
+ end
28
+
17
29
  initializer 'spree.api.environment', before: :load_config_initializers do |_app|
18
30
  Spree::Api::Config = Spree::Api::Configuration.new
19
31
  Spree::Api::Dependencies = Spree::Api::ApiDependencies.new
@@ -63,6 +75,7 @@ module Spree
63
75
  # not they run an agent client. The first caller pays instead.
64
76
  config.to_prepare do
65
77
  Spree::Api::AgentResourceMap.stale!
78
+ Spree::Api::AgentOperations.stale!
66
79
  end
67
80
 
68
81
  # Add API event subscribers
@@ -35,7 +35,10 @@ module Spree
35
35
  # @return [Array<Typelizer::Property>] those carrying a named list, nested and inline ones included
36
36
  def self.named_properties(properties)
37
37
  properties.flat_map do |property|
38
- nested = property.nested_properties.to_a
38
+ # Nested attributes are an inline shape (Typelizer 0.13+); an
39
+ # inline association is an inline interface.
40
+ nested = []
41
+ nested += property.type.properties if property.type.is_a?(Typelizer::Shape)
39
42
  nested += property.type.properties if property.type.is_a?(Typelizer::Interface) && property.type.inline?
40
43
 
41
44
  [property, *named_properties(nested)]
@@ -651,11 +651,11 @@ module Spree
651
651
  # Typelizer is normally disabled in test/production, but we need it
652
652
  # enabled to generate OpenAPI schemas from serializer type hints
653
653
  def with_typelizer_enabled
654
- original = ENV['DISABLE_TYPELIZER']
655
- ENV['DISABLE_TYPELIZER'] = 'false'
654
+ original = ENV['TYPELIZER']
655
+ ENV['TYPELIZER'] = 'true'
656
656
  yield
657
657
  ensure
658
- ENV['DISABLE_TYPELIZER'] = original
658
+ ENV['TYPELIZER'] = original
659
659
  end
660
660
  end
661
661
  end
@@ -0,0 +1,190 @@
1
+ require 'spree/api/typelizer_generation'
2
+
3
+ module Spree
4
+ module Api
5
+ # Generates the response types for a package that extends Spree's SDKs
6
+ # (docs/plans/6.0-sdk-extensions.md) from an extension's own serializers,
7
+ # with Typelizer, the way Spree generates its SDKs' types. Runs in the
8
+ # extension's test app, through the task in sdk_types_task.rb.
9
+ #
10
+ # Writes `<out>/<api>/types/` for each API the extension has
11
+ # serializers for, sorted by namespace as core's are (`::Admin::`,
12
+ # `::Seller::`, the rest Store), and `<out>/<api>/spree.ts` beside it. The
13
+ # generated files import every type they reference from `spree.ts`, which
14
+ # re-exports the extension's own types and the SDK's, so a `Quote` nesting
15
+ # an `Order` takes `Order` from `@spree/admin-sdk` instead of a copy.
16
+ #
17
+ # Only serializers defined under the engine's `app/serializers` are
18
+ # written. Named value lists are written into the package, open unless
19
+ # closed, since `@spree/sdk-core` is private to Spree's SDKs.
20
+ module SdkTypes
21
+ SDKS = {
22
+ store: '@spree/sdk',
23
+ admin: '@spree/admin-sdk',
24
+ seller: '@spree/seller-sdk'
25
+ }.freeze
26
+
27
+ HEADER = '// This file is generated from the serializers by `rake spree:sdk:serializer_types`. Do not edit it.'.freeze
28
+
29
+ class << self
30
+ # @param engine [Class<Rails::Engine>] the extension's engine
31
+ # @param out [String] where to write, relative to the engine's root
32
+ # @return [Array<Symbol>] the APIs types were written for
33
+ def generate!(engine:, out:)
34
+ serializers = engine.root.join('app/serializers')
35
+ out = engine.root.join(out)
36
+ configure(serializers, out)
37
+ refuse_name_collisions!
38
+ # Typelizer skips a writer with no serializers and leaves its folder
39
+ # as it was, so an API whose last serializer went away would keep
40
+ # publishing its old types.
41
+ SDKS.each_key do |api|
42
+ FileUtils.rm_rf(out.join(api.to_s, 'types'))
43
+ FileUtils.rm_f(out.join(api.to_s, 'spree.ts'))
44
+ end
45
+ run_typelizer
46
+
47
+ SDKS.keys.select do |api|
48
+ types = out.join(api.to_s, 'types')
49
+ next false unless types.exist?
50
+
51
+ TypelizerGeneration.use_interfaces!(types)
52
+ File.write(out.join(api.to_s, 'spree.ts'), barrel(api))
53
+ true
54
+ end
55
+ end
56
+
57
+ # @param serializer [Class]
58
+ # @return [Symbol] the API a serializer belongs to, by its namespace
59
+ def api_of(serializer)
60
+ name = serializer.name.to_s
61
+ return :admin if name.include?('::Admin::')
62
+ return :seller if name.include?('::Seller::')
63
+
64
+ :store
65
+ end
66
+
67
+ private
68
+
69
+ # Two serializers in one API named alike in different namespaces
70
+ # (`Orders::ItemSerializer`, `Returns::ItemSerializer`) would write one
71
+ # file and one type name, and a name the SDK already exports (`Order`)
72
+ # would make `spree.ts` export two types under it.
73
+ def refuse_name_collisions!
74
+ SDKS.each_key do |api|
75
+ interfaces = Typelizer.interfaces(writer_name: api)
76
+ clash = interfaces.group_by(&:name).find { |_name, named| named.size > 1 }
77
+ if clash
78
+ raise ArgumentError, "#{clash.last.map { |i| i.serializer.name }.join(' and ')} both generate the #{api} type " \
79
+ "#{clash.first}; rename one of the serializers"
80
+ end
81
+
82
+ taken = interfaces.find { |interface| core_type_names(api).include?(interface.name) }
83
+ if taken
84
+ raise ArgumentError, "#{taken.serializer.name} generates #{taken.name}, which #{SDKS[api]} already exports; " \
85
+ 'rename the serializer'
86
+ end
87
+
88
+ # A value list and a type of one name are both exported from the
89
+ # generated index, and one the SDK already has would be exported
90
+ # twice from `spree.ts`.
91
+ names = interfaces.map(&:name).to_set
92
+ lists = interfaces.flat_map(&:enum_types)
93
+ list = lists.find { |property| names.include?(property.enum_type_name) }
94
+ if list
95
+ raise ArgumentError, "The value list #{list.enum_type_name} is also the name of a type the #{api} API generates; " \
96
+ 'give its enum_type_name another name'
97
+ end
98
+
99
+ list = lists.find { |property| core_list_names(api).include?(property.enum_type_name) }
100
+ next unless list
101
+
102
+ raise ArgumentError, "The value list #{list.enum_type_name}, which #{SDKS[api]} already exports, is declared again; " \
103
+ 'give its enum_type_name another name'
104
+ end
105
+ end
106
+
107
+ # The type names Spree's own serializers give the SDK of one API, read
108
+ # from their file names and filtered as core's writers filter them.
109
+ def core_type_names(api)
110
+ @core_type_names ||= {}
111
+ @core_type_names[api] ||= begin
112
+ root = Spree::Api::Engine.root.join('app/serializers/spree/api/v3')
113
+ names = ->(pattern) { Dir[root.join(pattern)].to_set { |file| File.basename(file, '_serializer.rb').camelize } }
114
+ case api
115
+ when :store then names.call('*_serializer.rb') - TypelizerGeneration::STORE_EXCLUDED
116
+ when :admin then names.call('admin/**/*_serializer.rb')
117
+ when :seller then names.call('seller/**/*_serializer.rb') | TypelizerGeneration::SELLER_STORE_TYPES
118
+ end
119
+ end
120
+ end
121
+
122
+ # The value lists Spree's own serializers give the SDK of one API, read
123
+ # from their `enum_type_name:` declarations, and the shared code lists
124
+ # every SDK exports.
125
+ def core_list_names(api)
126
+ @core_list_names ||= {}
127
+ @core_list_names[api] ||= begin
128
+ root = Spree::Api::Engine.root.join('app/serializers/spree/api/v3')
129
+ patterns = { store: ['*_serializer.rb'], admin: ['admin/**/*_serializer.rb', '*_serializer.rb'],
130
+ seller: ['seller/**/*_serializer.rb', '*_serializer.rb'] }.fetch(api)
131
+ declared = patterns.flat_map { |pattern| Dir[root.join(pattern)] }
132
+ .flat_map { |file| File.read(file).scan(/enum_type_name: ['"](\w+)['"]/).flatten }
133
+ declared.to_set | Typelizer::OpenEnums::CLOSED_TYPE_NAMES
134
+ end
135
+ end
136
+
137
+ # Replaces the writers Spree configured for its own SDKs: run in an
138
+ # extension's app, they would write core's types into the installed gem.
139
+ def configure(serializers, out)
140
+ ours = ->(serializer) { Object.const_source_location(serializer.name)&.first.to_s.start_with?("#{serializers}/") }
141
+
142
+ Typelizer.configure do |config|
143
+ config.reset_writers!
144
+ config.dirs = [serializers]
145
+ config.listen = false
146
+ config.comments = true
147
+ config.plugin_configs = TypelizerGeneration.plugin_configs
148
+ # The default writer writes nothing; each API has its own below.
149
+ config.reject_class = ->(serializer:) { true }
150
+
151
+ SDKS.each_key do |api|
152
+ config.writer(api) do |writer|
153
+ writer.output_dir = out.join(api.to_s, 'types')
154
+ writer.types_import_path = '../spree'
155
+ # Named type exports compile whether or not the package sets
156
+ # `verbatimModuleSyntax`; a default export of a type does not.
157
+ writer.verbatim_module_syntax = true
158
+ writer.reject_class = ->(serializer:) { !ours.call(serializer) || api_of(serializer) != api }
159
+ writer.serializer_name_mapper = ->(serializer) { serializer.name.to_s.demodulize.delete_suffix('Serializer') }
160
+ end
161
+ end
162
+ end
163
+ end
164
+
165
+ def run_typelizer
166
+ enabled = ENV['TYPELIZER']
167
+ source = Typelizer::OpenEnums.source
168
+ ENV['TYPELIZER'] = 'true'
169
+ Typelizer::OpenEnums.source = nil
170
+
171
+ require 'typelizer/generator'
172
+ Typelizer::Generator.call(force: true)
173
+ ensure
174
+ ENV['TYPELIZER'] = enabled
175
+ Typelizer::OpenEnums.source = source
176
+ end
177
+
178
+ def barrel(api)
179
+ <<~TS
180
+ #{HEADER}
181
+
182
+ // Every type the generated files reference: the SDK's, and this package's own.
183
+ export type * from '#{SDKS[api]}'
184
+ export type * from './types'
185
+ TS
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,68 @@
1
+ # `rake spree:sdk:serializer_types`: writes the response types of a package
2
+ # that extends Spree's SDKs from the serializers of the extension or app it
3
+ # runs in (see sdk_types.rb). `spree sdk generate` runs it with OUT set to the
4
+ # package's output folder; nothing else has to be configured.
5
+ #
6
+ # An extension gets the task from `spree/testing_support/extension_rake`, and
7
+ # runs it against its test app (`bundle exec rake test_app` first). A Spree
8
+ # application gets it from spree_api's own tasks, against itself.
9
+ require 'rake'
10
+
11
+ module Spree
12
+ module Api
13
+ class SdkTypesTask
14
+ include Rake::DSL
15
+
16
+ # @param engine [String, nil] the engine class; by default the engine,
17
+ # or the application, whose root is the current folder
18
+ # @param out [String, nil] where to write; by default `ENV['OUT']`
19
+ # @param dummy [String] an extension's test app environment file
20
+ def initialize(engine: nil, out: nil, dummy: 'spec/dummy/config/environment.rb')
21
+ return if Rake::Task.task_defined?('spree:sdk:serializer_types')
22
+
23
+ namespace :spree do
24
+ namespace :sdk do
25
+ desc "Generate the SDK response types from this extension's or app's serializers (OUT=folder)"
26
+ task :serializer_types do
27
+ target = out || ENV['OUT']
28
+ abort 'Pass OUT, the folder to write the types to, or run `spree sdk generate`.' if target.to_s.empty?
29
+
30
+ boot(dummy)
31
+ require 'spree/api/sdk_types'
32
+
33
+ owner = engine ? engine.constantize : self.class.owner_here
34
+ abort "No engine or application has its root in #{Dir.pwd}" unless owner
35
+
36
+ apis = Spree::Api::SdkTypes.generate!(engine: owner, out: File.expand_path(target))
37
+ puts apis.any? ? "Wrote the #{apis.join(', ')} response types to #{target}" : "No serializers under #{owner.root}/app/serializers"
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ # @return [Class<Rails::Engine>, Rails::Application, nil] the engine or
44
+ # application whose root is the current folder
45
+ def self.owner_here
46
+ here = File.expand_path(Dir.pwd)
47
+ Rails::Engine.subclasses.find { |engine| File.expand_path(engine.root.to_s) == here } ||
48
+ (Rails.application if File.expand_path(Rails.application.root.to_s) == here)
49
+ end
50
+
51
+ private
52
+
53
+ # Typelizer records the serializers' `typelize` declarations only when
54
+ # TYPELIZER is on as the app boots, so it is set first.
55
+ def boot(dummy)
56
+ ENV['TYPELIZER'] = 'true'
57
+ if File.exist?(dummy)
58
+ ENV['RAILS_ENV'] ||= 'test'
59
+ require File.expand_path(dummy)
60
+ elsif Rake::Task.task_defined?(:environment)
61
+ Rake::Task[:environment].invoke
62
+ else
63
+ abort 'Build the test app first: bundle exec rake test_app'
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,35 @@
1
+ module Spree
2
+ module Api
3
+ module TestingSupport
4
+ # rswag helpers for what an admin operation means to agents. Every
5
+ # write action an admin key may call is an agent tool built from its
6
+ # operation; these mark the exceptions in the document itself, where a
7
+ # reviewer sees them next to the endpoint.
8
+ #
9
+ # patch 'Anonymize a customer' do
10
+ # agent_tool false, reason: 'Irreversible erasure under GDPR Art. 17.'
11
+ # end
12
+ #
13
+ # get 'List quote statuses' do
14
+ # agent_tool true # a read becomes a tool only when it opts in
15
+ # end
16
+ #
17
+ # Core and extensions extend their request specs with this module:
18
+ #
19
+ # RSpec.configure { |config| config.extend Spree::Api::TestingSupport::AgentTools, type: :request }
20
+ #
21
+ # See docs/plans/6.0-agent-tools-from-openapi.md.
22
+ module AgentTools
23
+ # @param enabled [Boolean]
24
+ # @param reason [String, nil] required to opt a write out
25
+ # @return [void]
26
+ def agent_tool(enabled = true, reason: nil)
27
+ raise ArgumentError, 'an operation opted out of agent tools needs a reason' if !enabled && reason.blank?
28
+
29
+ metadata[:operation][Spree::Api::AgentOperations::AGENT_KEY.to_sym] = enabled
30
+ metadata[:operation][Spree::Api::AgentOperations::REASON_KEY.to_sym] = reason if reason
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,110 @@
1
+ require 'spree/api/testing_support/agent_tools'
2
+
3
+ # The contract between an engine's admin endpoints and the agent tools built
4
+ # from them. Core includes it, and so does every extension that adds admin
5
+ # endpoints:
6
+ #
7
+ # require 'spree/api/testing_support/agent_tools_contract'
8
+ #
9
+ # RSpec.describe 'agent tools' do
10
+ # it_behaves_like 'an engine whose admin writes are agent tools', MyExtension::Engine
11
+ # end
12
+ #
13
+ # An admin write cannot land without an operation describing it, an opt-out
14
+ # without its reason, or two operations claiming one tool name.
15
+ #
16
+ # See docs/plans/6.0-agent-tools-from-openapi.md.
17
+ RSpec.shared_examples 'an engine whose admin writes are agent tools' do |engine|
18
+ operations = Spree::Api::AgentOperations
19
+ root = engine.root.to_s
20
+ defined_here = lambda do |controller|
21
+ controller && Object.const_source_location(controller.name)&.first&.start_with?(root)
22
+ end
23
+
24
+ # The admin operations this engine's own controllers serve.
25
+ let(:engine_candidates) do
26
+ operations.candidates.select { |candidate| defined_here.call(candidate.dig(:route, :controller)) }
27
+ end
28
+
29
+ let(:engine_routes) do
30
+ operations.admin_routes.filter_map do |route|
31
+ next unless operations::WRITE_VERBS.include?(route[:verb]) && !operations::CRUD_ACTIONS.include?(route[:action])
32
+
33
+ [route[:verb], route[:path]] if defined_here.call(route[:controller])
34
+ end.uniq
35
+ end
36
+
37
+ # The tool name each listed action derives, before collisions are settled.
38
+ let(:included_names) do
39
+ engine_candidates.select { |candidate| operations.disposition(candidate) == :included }.
40
+ map { |candidate| operations.tool_name(candidate) }
41
+ end
42
+
43
+ it 'documents every admin action its controllers serve' do
44
+ paths = Spree::Api::AdminDocument.document['paths'] || {}
45
+ undocumented = engine_routes.reject { |verb, path| paths.dig(path, verb) }.
46
+ map { |verb, path| "#{verb.upcase} #{path}" }
47
+
48
+ expect(undocumented).to be_empty, <<~MESSAGE
49
+ These admin actions have no operation in the engine's OpenAPI document, so no
50
+ agent tool and no API reference describes them:
51
+
52
+ #{undocumented.join("\n ")}
53
+
54
+ Write an rswag integration spec for each, then regenerate the document.
55
+ MESSAGE
56
+ end
57
+
58
+ it 'gives every opt-out a reason' do
59
+ unexplained = engine_candidates.filter_map do |candidate|
60
+ operation = candidate[:operation]
61
+ next unless operation[operations::AGENT_KEY] == false && operation[operations::REASON_KEY].blank?
62
+
63
+ "#{candidate[:verb].upcase} #{candidate[:path]}"
64
+ end
65
+
66
+ expect(unexplained).to be_empty
67
+ end
68
+
69
+ # Across every engine, not only this one: an extension's action deriving
70
+ # a core name would otherwise lose to the shallower path, quietly.
71
+ it 'derives a distinct tool name for every action' do
72
+ every_name = operations.candidates.select { |candidate| operations.disposition(candidate) == :included }.
73
+ map { |candidate| operations.tool_name(candidate) }
74
+ duplicated = every_name.tally.select { |name, count| count > 1 && included_names.include?(name) }.keys
75
+
76
+ expect(duplicated).to be_empty, "Give these operations an operationId: #{duplicated.join(', ')}"
77
+ end
78
+
79
+ # A hand-registered tool keeps its name, so an operation deriving the same
80
+ # one would vanish from every agent surface.
81
+ it 'claims no name a hand-written tool already has' do
82
+ hand_written = Spree.admin_agent_tools.send(:registered_classes).map(&:tool_name)
83
+
84
+ expect(included_names & hand_written).to be_empty
85
+ end
86
+
87
+ it 'merges its document into core\'s without a conflict' do
88
+ expect(Spree::Api::AdminDocument.conflicts).to be_empty
89
+ end
90
+
91
+ it 'names every tool the way MCP accepts' do
92
+ catalog = Spree::AgentTools::OperationCatalog
93
+ invalid = (catalog.all + catalog.aliases).map(&:name).
94
+ reject { |name| name.match?(/\A[a-zA-Z0-9_-]{1,64}\z/) }
95
+
96
+ expect(invalid).to be_empty
97
+ end
98
+
99
+ it 'gives every tool a summary and a permission the catalog knows' do
100
+ faults = included_names.filter_map do |name|
101
+ entry = Spree::AgentTools::OperationCatalog.find(name)
102
+ next "#{name}: not registered" if entry.nil?
103
+ next "#{name}: no summary" if entry.summary.blank?
104
+
105
+ "#{name}: unknown permission #{entry.permission}" unless Spree.permissions.key?(entry.permission)
106
+ end
107
+
108
+ expect(faults).to be_empty
109
+ end
110
+ end