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
|
@@ -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
|
-
#
|
|
15
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
43
|
-
source.exist? ? source : Spree::Api::Engine.root.join('openapi', 'admin.yaml')
|
|
40
|
+
AdminDocument.core_path
|
|
44
41
|
end
|
|
45
42
|
|
|
46
|
-
# The
|
|
47
|
-
#
|
|
48
|
-
#
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
data/lib/spree/api/engine.rb
CHANGED
|
@@ -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 '
|
|
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
|
data/lib/spree/api/enum_types.rb
CHANGED
|
@@ -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
|
-
|
|
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['
|
|
655
|
-
ENV['
|
|
654
|
+
original = ENV['TYPELIZER']
|
|
655
|
+
ENV['TYPELIZER'] = 'true'
|
|
656
656
|
yield
|
|
657
657
|
ensure
|
|
658
|
-
ENV['
|
|
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
|