spree_api 6.0.2 → 6.0.4

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 (71) 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/concerns/spree/api/v3/idempotent.rb +10 -1
  4. data/app/controllers/concerns/spree/api/v3/security_headers.rb +3 -0
  5. data/app/controllers/spree/api/v3/admin/base_controller.rb +31 -0
  6. data/app/controllers/spree/api/v3/admin/companies/memberships_controller.rb +21 -4
  7. data/app/controllers/spree/api/v3/admin/diagnostics_controller.rb +46 -0
  8. data/app/controllers/spree/api/v3/admin/me_controller.rb +0 -13
  9. data/app/controllers/spree/api/v3/admin/media_controller.rb +10 -0
  10. data/app/controllers/spree/api/v3/admin/orders/items_controller.rb +6 -2
  11. data/app/controllers/spree/api/v3/admin/products_controller.rb +56 -0
  12. data/app/controllers/spree/api/v3/admin/sellers/invitations_controller.rb +39 -3
  13. data/app/controllers/spree/api/v3/admin/sellers/team_controller.rb +1 -3
  14. data/app/controllers/spree/api/v3/admin/sellers_controller.rb +24 -1
  15. data/app/controllers/spree/api/v3/admin/stock_levels_controller.rb +15 -1
  16. data/app/controllers/spree/api/v3/admin/updates_controller.rb +4 -15
  17. data/app/controllers/spree/api/v3/seller/products/media_controller.rb +46 -0
  18. data/app/controllers/spree/api/v3/seller/products_controller.rb +4 -2
  19. data/app/controllers/spree/api/v3/seller/profile_controller.rb +13 -5
  20. data/app/controllers/spree/api/v3/store/carts_controller.rb +10 -7
  21. data/app/serializers/spree/api/v3/admin/admin_user_serializer.rb +2 -2
  22. data/app/serializers/spree/api/v3/admin/company_membership_serializer.rb +4 -0
  23. data/app/serializers/spree/api/v3/admin/delivery_profile_serializer.rb +2 -1
  24. data/app/serializers/spree/api/v3/admin/diagnostics_serializer.rb +30 -0
  25. data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +1 -1
  26. data/app/serializers/spree/api/v3/admin/import_serializer.rb +1 -1
  27. data/app/serializers/spree/api/v3/admin/invitation_serializer.rb +2 -2
  28. data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +6 -1
  29. data/app/serializers/spree/api/v3/admin/price_rule_serializer.rb +3 -1
  30. data/app/serializers/spree/api/v3/admin/product_serializer.rb +8 -0
  31. data/app/serializers/spree/api/v3/admin/promotion_action_serializer.rb +2 -2
  32. data/app/serializers/spree/api/v3/admin/promotion_rule_serializer.rb +4 -1
  33. data/app/serializers/spree/api/v3/admin/promotion_serializer.rb +13 -0
  34. data/app/serializers/spree/api/v3/admin/return_serializer.rb +1 -1
  35. data/app/serializers/spree/api/v3/admin/seller_requirement_status_serializer.rb +3 -2
  36. data/app/serializers/spree/api/v3/admin/seller_serializer.rb +12 -2
  37. data/app/serializers/spree/api/v3/fulfillment_serializer.rb +1 -1
  38. data/app/serializers/spree/api/v3/product_category_serializer.rb +26 -0
  39. data/app/serializers/spree/api/v3/product_collection_serializer.rb +26 -0
  40. data/app/serializers/spree/api/v3/seller/import_serializer.rb +1 -1
  41. data/app/serializers/spree/api/v3/seller/invitation_serializer.rb +2 -2
  42. data/app/serializers/spree/api/v3/seller/order_line_item_serializer.rb +2 -2
  43. data/app/serializers/spree/api/v3/seller/product_serializer.rb +8 -0
  44. data/app/serializers/spree/api/v3/seller/requirement_status_serializer.rb +3 -2
  45. data/config/initializers/typelizer.rb +8 -19
  46. data/config/routes.rb +8 -1
  47. data/lib/spree/api/admin_document.rb +284 -0
  48. data/lib/spree/api/agent_operations.rb +300 -0
  49. data/lib/spree/api/agent_resource_map.rb +74 -14
  50. data/lib/spree/api/agent_write_schemas.rb +15 -51
  51. data/lib/spree/api/dependencies.rb +1 -1
  52. data/lib/spree/api/engine.rb +14 -1
  53. data/lib/spree/api/enum_types.rb +4 -1
  54. data/lib/spree/api/openapi/schema_helper.rb +3 -3
  55. data/lib/spree/api/sdk_types.rb +190 -0
  56. data/lib/spree/api/sdk_types_task.rb +68 -0
  57. data/lib/spree/api/testing_support/agent_tools.rb +35 -0
  58. data/lib/spree/api/testing_support/agent_tools_contract.rb +110 -0
  59. data/lib/spree/api/testing_support/deterministic_openapi.rb +147 -0
  60. data/lib/spree/api/testing_support/filter_parameters.rb +99 -0
  61. data/lib/spree/api/testing_support/openapi.rb +233 -0
  62. data/lib/spree/api/testing_support/openapi_tasks.rb +21 -0
  63. data/lib/spree/api/testing_support/sdk_method.rb +34 -0
  64. data/lib/spree/api/typelizer_generation.rb +39 -11
  65. data/lib/tasks/openapi.rake +5 -0
  66. data/lib/tasks/sdk_types.rake +6 -0
  67. data/lib/typelizer/open_enums.rb +13 -1
  68. data/openapi/admin.yaml +2241 -318
  69. data/openapi/store.yaml +125 -125
  70. metadata +26 -9
  71. data/lib/spree/api/agent_workflow_endpoints.rb +0 -113
@@ -71,16 +71,23 @@ module Spree
71
71
  # one, because the scope belongs to the resource being imported or
72
72
  # exported — creating a product import is a product write, and reading
73
73
  # its rows exposes the uploaded data. A single static key cannot say
74
- # that, so these are registered read-only under the broadest key the
75
- # controller can demand, and the write path stays with the endpoint.
74
+ # that, so these open to a caller holding the scope of any one type,
75
+ # in the kind the controller asks for: `read_` for an export, `write_`
76
+ # for an import. The endpoint then keeps to the types that key covers.
77
+ #
78
+ # Creating one stays with create_import and create_export; reading,
79
+ # listing and deleting go to the endpoint.
76
80
  DYNAMIC_SCOPE_RESOURCES = {
77
- 'imports' => { model_name: 'Spree::Import', permission: 'read_settings',
81
+ 'imports' => { model_name: 'Spree::Import', access_kind: 'write',
78
82
  serializer_name: 'Spree::Api::V3::Admin::ImportSerializer',
79
83
  dashboard_path: '/settings/imports' },
80
- 'exports' => { model_name: 'Spree::Export', permission: 'read_settings',
84
+ 'exports' => { model_name: 'Spree::Export', access_kind: 'read',
81
85
  serializer_name: 'Spree::Api::V3::Admin::ExportSerializer' }
82
86
  }.freeze
83
87
 
88
+ # The operations the generic tools reach on those resources.
89
+ DYNAMIC_SCOPE_ACTIONS = %i[index show destroy].freeze
90
+
84
91
  class << self
85
92
  # Registers this walk as how the core map fills itself, and marks the
86
93
  # map stale so the next reader gets a fresh one. Called on every code
@@ -102,7 +109,11 @@ module Spree
102
109
  registered = resolve_collisions(controllers.filter_map { |controller| derive(controller) })
103
110
  registered.each { |entry| map.register(**entry) }
104
111
 
105
- DYNAMIC_SCOPE_RESOURCES.each { |key, attributes| map.register(key: key, **attributes) }
112
+ DYNAMIC_SCOPE_RESOURCES.each do |key, attributes|
113
+ map.register(key: key, permission: nil, **attributes.except(:access_kind),
114
+ api_paths: dynamic_scope_paths(attributes[:model_name]),
115
+ access_permissions: type_permissions(attributes[:model_name], attributes[:access_kind]))
116
+ end
106
117
 
107
118
  registered.size + DYNAMIC_SCOPE_RESOURCES.size
108
119
  end
@@ -142,6 +153,15 @@ module Spree
142
153
  transform_values { |pairs| pairs.map(&:last).min_by { |path| [path.count(':'), path.length] } }
143
154
  end
144
155
 
156
+ # The controller served at the shallowest path, which is where a tool
157
+ # dispatches: for a record it is the show path, else the collection.
158
+ def shallowest_candidate(candidates)
159
+ candidates.min_by do |entry|
160
+ path = Array(entry[:api_paths]).to_h.values_at(:show, :index).compact.first.to_s
161
+ [path.empty? ? Float::INFINITY : path.count(':'), path.length]
162
+ end
163
+ end
164
+
145
165
  def api_paths_for(controller)
146
166
  candidates = Spree::Core::Engine.routes.routes.filter_map do |route|
147
167
  next unless route.defaults[:controller] == controller.controller_path
@@ -174,6 +194,7 @@ module Spree
174
194
  create_workflow = workflow_key(instance, :create_workflow)
175
195
  update_workflow = workflow_key(instance, :update_workflow)
176
196
  writable = AgentWriteSchemas.attribute_names(key)
197
+ api_paths = api_paths_for(controller)
177
198
 
178
199
  {
179
200
  key: key,
@@ -181,7 +202,8 @@ module Spree
181
202
  permission: "read_#{scope}",
182
203
  serializer_name: serializer.name,
183
204
  dashboard_path: DASHBOARD_PATHS[key],
184
- api_paths: api_paths_for(controller),
205
+ api_paths: api_paths,
206
+ index_paths: Array(api_paths[:index]),
185
207
  write_permission: write_permission_for(model, scope, create_workflow, update_workflow, writable, controller),
186
208
  writable_attributes: writable,
187
209
  create_workflow_key: create_workflow,
@@ -190,8 +212,45 @@ module Spree
190
212
  }
191
213
  end
192
214
 
215
+ # The plural the resource is addressed by, taken from the model so two
216
+ # controllers serving the same model (a nested one and a top-level one)
217
+ # agree on it. The operation catalog keys its tools the same way.
218
+ #
219
+ # @param model [Class]
220
+ # @return [String]
221
+ def key_for(model)
222
+ model.model_name.element.pluralize
223
+ end
224
+
225
+ # The model a controller serves, or nil for one that serves none.
226
+ #
227
+ # @param controller [Class]
228
+ # @return [Class, nil]
229
+ def model_for(controller)
230
+ safely(controller.allocate, :model_class)
231
+ end
232
+
193
233
  private
194
234
 
235
+ # The read and delete paths of the controller serving an import or
236
+ # export. Without them the generic tools refused every one, though
237
+ # create_export tells the agent to read the export with get_resource.
238
+ def dynamic_scope_paths(model_name)
239
+ controller = controllers.find { |candidate| model_for(candidate)&.name == model_name }
240
+ return {} if controller.nil?
241
+
242
+ api_paths_for(controller).slice(*DYNAMIC_SCOPE_ACTIONS)
243
+ end
244
+
245
+ # The catalog keys that open an import or export of each registered
246
+ # type. A type with no scope of its own needs `read_all` or
247
+ # `write_all`, which no catalog key stands for, so it adds none.
248
+ def type_permissions(model_name, kind)
249
+ model_name.constantize.available_types.
250
+ filter_map { |type| type.required_scope.presence && "#{kind}_#{type.required_scope}" }.
251
+ uniq.select { |key| Spree.permissions.key?(key) }
252
+ end
253
+
195
254
  # What the controller does with its own collection. A controller that
196
255
  # disables DISTINCT has a reason — Postgres refuses it beside an
197
256
  # ORDER BY the select list does not carry — and a search tool adding
@@ -228,6 +287,11 @@ module Spree
228
287
  # withdrawn, and only when the controllers disagree about the
229
288
  # scope guarding it.
230
289
  narrowest.merge(
290
+ # The serializer of the controller the tools actually reach:
291
+ # the one whose path won below. `narrowest` is chosen on
292
+ # permission, which left `products` described by the catalog
293
+ # membership serializer and `expand: ["variants"]` refused.
294
+ serializer_name: shallowest_candidate(candidates)[:serializer_name],
231
295
  create_workflow_key: candidates.filter_map { |entry| entry[:create_workflow_key] }.first,
232
296
  update_workflow_key: candidates.filter_map { |entry| entry[:update_workflow_key] }.first,
233
297
  write_permission: contested ? nil : narrowest[:write_permission],
@@ -241,7 +305,10 @@ module Spree
241
305
  # nothing about depth — so a resource served both at the top
242
306
  # level and under a parent kept whichever won on scope, and a
243
307
  # tool was handed a path with an unfillable `:parent_id` in it.
244
- api_paths: shallowest_paths(candidates)
308
+ api_paths: shallowest_paths(candidates),
309
+ # Every collection path, not only the shallowest, so a search
310
+ # under a parent reaches the controller that lists its records.
311
+ index_paths: candidates.flat_map { |entry| entry[:index_paths] }.uniq
245
312
  )
246
313
  end
247
314
  end
@@ -299,13 +366,6 @@ module Spree
299
366
  false
300
367
  end
301
368
 
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
369
  # `Spree::Base.for_store` falls back to the bare class for a model with
310
370
  # no Store association, which would hand this store's agent every
311
371
  # store's rows. A model that defines its own `for_store` has made a
@@ -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
@@ -38,7 +38,7 @@ module Spree
38
38
  channel order_routing_rule product_publication market delivery_method delivery_method_service
39
39
  stock_location stock_reservation delivery_rate freight_summary freight_summary_line payment_method
40
40
  credit_card store_credit store_credit_event customer_group payment_source digital_asset digital_link
41
- store setup_task update_status api_key oauth_application oauth_access_token oauth_consent_request oauth_redirect
41
+ store setup_task update_status diagnostics api_key oauth_application oauth_access_token oauth_consent_request oauth_redirect
42
42
  oauth_protected_resource allowed_origin webhook_endpoint webhook_event webhook_delivery invitation
43
43
  invitation_acceptance_link role permission export saved_report import import_row import_mapping promotion
44
44
  maintenance_task_run
@@ -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