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
@@ -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
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The OpenAPI specs in docs/api-reference/ embed real response bodies captured
4
+ # while the integration suite runs, so anything the suite generates at random
5
+ # lands in a checked-in file. Two runs of unchanged code used to produce a
6
+ # ~1,800 line diff, which buried real API changes in noise during review.
7
+ #
8
+ # Under OPENAPI=true (set by the swaggerize rake task) every source of that
9
+ # noise is pinned, so regenerating the specs yields a byte-identical file
10
+ # unless the API itself changed. Nothing here is loaded by a normal spec run —
11
+ # the suite keeps its random names and real clock.
12
+ if ENV['OPENAPI'] == 'true'
13
+ require 'securerandom'
14
+ require 'digest/md5'
15
+ require 'ffaker'
16
+
17
+ module Spree
18
+ module OpenAPIDeterminism
19
+ # The instant every generated example is stamped with. Anything derived
20
+ # from it — an expiry fourteen days out, a promotion window — lands on a
21
+ # stable value too, so a whole run reduces to this one timestamp. Bump it
22
+ # only when the dates in the published examples start looking stale; each
23
+ # bump rewrites every timestamp in the checked-in specs.
24
+ FROZEN_TIME = Time.utc(2026, 1, 15, 12, 0, 0)
25
+
26
+ # Base seed for the two pseudo-random generators in play: Ruby's global
27
+ # one, which Kernel.rand draws from, and FFaker's own, which is separate
28
+ # and needs seeding through its own API. Both are mixed with the example's
29
+ # identity for the same reason the token seeds are: a run-wide constant
30
+ # would hand every example the same "random" name and email, and the
31
+ # unique ones then collide on a real index.
32
+ RANDOM_SEED = 20_260_115
33
+
34
+ # Signing secret for the JWTs shown in the examples. It signs nothing
35
+ # real — these tokens are documentation, generated against a throwaway
36
+ # dummy app — but it has to be the same everywhere for the signatures
37
+ # to match across machines.
38
+ JWT_SECRET = 'openapi-example-signing-secret'
39
+
40
+ # SecureRandom bypasses the global PRNG (it reads from the OS), so seeding
41
+ # alone leaves API keys, invitation tokens and JWT ids churning. These
42
+ # counter-backed replacements produce values with the right shape and
43
+ # length for anything that stores or displays them.
44
+ module PredictableSecureRandom
45
+ HEX_ALPHABET = '0123456789abcdef'
46
+ BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
47
+ BASE36_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
48
+ URL_SAFE_ALPHABET = "#{BASE36_ALPHABET}ABCDEFGHIJKLMNOPQRSTUVWXYZ-_"
49
+
50
+ class << self
51
+ # Values must be unique across the whole run (they back columns with
52
+ # unique indexes, such as a store's setup token) while still being
53
+ # reproducible. Deriving the seed from the current example's id plus a
54
+ # per-example counter gives both: unique because no two examples share
55
+ # an id, reproducible because an example's id does not change between
56
+ # runs, and stable under edits because adding one spec cannot shift
57
+ # the values another spec produces.
58
+ def scope!(example_key)
59
+ @example_key = example_key
60
+ @sequence = 0
61
+ end
62
+
63
+ # Digest rather than String#hash: Ruby randomizes String#hash per
64
+ # process, which is exactly the churn this file exists to remove.
65
+ def next_seed
66
+ @sequence = (@sequence || 0) + 1
67
+ Digest::MD5.hexdigest("#{@example_key}/#{@sequence}")[0, 15].to_i(16)
68
+ end
69
+
70
+ def string_from(alphabet, length)
71
+ generator = Random.new(next_seed)
72
+ Array.new(length) { alphabet[generator.rand(alphabet.length)] }.join
73
+ end
74
+ end
75
+
76
+ # Callers pass nil to mean "the default length", so every override
77
+ # normalizes it rather than doing arithmetic on it.
78
+ def hex(length = nil)
79
+ PredictableSecureRandom.string_from(HEX_ALPHABET, (length || 16) * 2)
80
+ end
81
+
82
+ def base58(length = nil)
83
+ PredictableSecureRandom.string_from(BASE58_ALPHABET, length || 16)
84
+ end
85
+
86
+ def base36(length = nil)
87
+ PredictableSecureRandom.string_from(BASE36_ALPHABET, length || 16)
88
+ end
89
+
90
+ def alphanumeric(length = nil, chars: nil)
91
+ return super if chars
92
+
93
+ PredictableSecureRandom.string_from(BASE58_ALPHABET, length || 16)
94
+ end
95
+
96
+ def urlsafe_base64(length = nil, _padding = false)
97
+ PredictableSecureRandom.string_from(URL_SAFE_ALPHABET, ((length || 16) * 4 / 3.0).ceil)
98
+ end
99
+
100
+ def uuid
101
+ digits = PredictableSecureRandom.string_from(HEX_ALPHABET, 32)
102
+ [digits[0, 8], digits[8, 4], "4#{digits[13, 3]}", "a#{digits[17, 3]}", digits[20, 12]].join('-')
103
+ end
104
+
105
+ def bytes(length)
106
+ PredictableSecureRandom.string_from(HEX_ALPHABET, (length || 16) * 2)[0, length || 16]
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+ SecureRandom.singleton_class.prepend(Spree::OpenAPIDeterminism::PredictableSecureRandom)
113
+
114
+ RSpec.configure do |config|
115
+ config.before do
116
+ travel_to(Spree::OpenAPIDeterminism::FROZEN_TIME)
117
+ end
118
+
119
+ # Reset before each example rather than once for the run, so a value shown
120
+ # in one example does not depend on how many records the examples before it
121
+ # happened to create. Without this, adding a spec shifts every random value
122
+ # generated after it and the diff spreads across unrelated endpoints.
123
+ #
124
+ # FactoryBot's own sequences are deliberately left alone: they already count
125
+ # from one per process, so they are stable, and rewinding them mid-run would
126
+ # hand out an SKU or email that another example already claimed.
127
+ config.before do |example|
128
+ example_key = example.metadata[:full_description]
129
+ example_seed = Spree::OpenAPIDeterminism::RANDOM_SEED ^ Digest::MD5.hexdigest(example_key)[0, 15].to_i(16)
130
+
131
+ srand(example_seed)
132
+ FFaker::Random.seed = example_seed
133
+ Spree::OpenAPIDeterminism::PredictableSecureRandom.scope!(example_key)
134
+
135
+ # The examples include signed JWTs. Their payloads are already stable
136
+ # (a frozen clock fixes `exp`, the SecureRandom override fixes `jti`),
137
+ # but the signature also depends on the signing secret, which otherwise
138
+ # falls through to the dummy app's `secret_key_base` — generated when
139
+ # that app is built and so different on every machine. Without this the
140
+ # specs reproduce for whoever generated them and for nobody else.
141
+ #
142
+ # Set per example, not once for the suite: the suite resets Spree's
143
+ # preferences before every example, which would wipe a one-off value.
144
+ Spree::Api::Config[:jwt_secret_key] = Spree::OpenAPIDeterminism::JWT_SECRET
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,99 @@
1
+ require 'spree/api/openapi/filter_documentation'
2
+
3
+ module Spree
4
+ module Api
5
+ module TestingSupport
6
+ # Documents a list endpoint's filters from the model allowlists, so no
7
+ # integration spec lists `q[...]` parameters by hand.
8
+ #
9
+ # path '/api/v3/admin/orders' do
10
+ # get 'List orders' do
11
+ # filter_parameters_for search: 'Order number, purchase order or customer name'
12
+ #
13
+ # Adds `q[search]` first and the endpoint's other scopes as named query
14
+ # parameters, one `q` parameter for attribute filters, the attribute table
15
+ # above the endpoint (`x-mint.content`), and the machine-readable
16
+ # `x-spree-filters` extension. Every table the filters reach is registered
17
+ # under the spec's `components.x-spree-filter-tables`, which is what the SDK
18
+ # filter types are generated from.
19
+ module FilterParameters
20
+ FILTER_TABLES_KEY = :'x-spree-filter-tables'
21
+ FILTER_PREDICATES_KEY = :'x-spree-filter-predicates'
22
+ # What `q[search]` matches on models whose search is written by hand rather
23
+ # than declared with `search_by` (which describes itself).
24
+ SEARCH_DESCRIPTIONS = {
25
+ 'Product' => 'Matches product name or variant SKU (partial, case-insensitive), and the store\'s searchable custom fields.',
26
+ 'Variant' => 'Matches SKU, option value or product name (partial, case-insensitive; at least three characters).',
27
+ 'Price' => 'Matches the variant\'s SKU, option value or product name, as variant search does.',
28
+ 'Customer' => 'Matches email, first name, last name or full name (partial, case-insensitive).',
29
+ 'Order' => 'Matches order number or purchase order number (partial), the billing first or last name, or the exact email.'
30
+ }.freeze
31
+
32
+ STORE_QUERYING_PAGE = '/api-reference/store-api/querying#which-filters-an-endpoint-accepts'
33
+ ADMIN_QUERYING_PAGE = '/api-reference/admin-api/querying#which-filters-an-endpoint-accepts'
34
+
35
+ # @param controller_class [Class, nil] the controller serving the list;
36
+ # resolved from the path when omitted
37
+ # @param search [String, nil] what `q[search]` matches, required when the
38
+ # model's search is not declared with `search_by`
39
+ # @param scopes [Hash{Symbol => String}] descriptions of other scopes
40
+ def filter_parameters_for(controller_class = nil, search: nil, scopes: {})
41
+ endpoint = Spree::Api::V3::FilterTable.for(controller_class || filter_controller_for_path)
42
+ docs = Spree::Api::OpenAPI::FilterDocumentation.new(
43
+ endpoint, search: search || SEARCH_DESCRIPTIONS[endpoint.table.name], scopes: scopes,
44
+ querying_path: spec_name.to_s.end_with?('store.yaml') ? STORE_QUERYING_PAGE : ADMIN_QUERYING_PAGE
45
+ )
46
+
47
+ parameter name: :q, in: :query, required: false, style: :deepObject, explode: true,
48
+ schema: { type: :object, additionalProperties: true },
49
+ description: 'Attribute filters, as `q[<attribute>_<predicate>]=value`. See Filters above for the list.'
50
+ insert_before_query_parameters(docs.parameters)
51
+
52
+ metadata[:operation][:'x-mint'] = (metadata[:operation][:'x-mint'] || {}).merge(content: docs.content)
53
+ metadata[:operation][:'x-spree-filters'] = endpoint.to_h
54
+ register_filter_tables(endpoint.tables)
55
+ end
56
+
57
+ private
58
+
59
+ def filter_controller_for_path
60
+ path = metadata[:path_item][:template].gsub(/\{[^}]+\}/, 'x1')
61
+ route = Rails.application.routes.recognize_path(path, method: :get)
62
+ "#{route[:controller]}_controller".camelize.constantize
63
+ end
64
+
65
+ # rswag routes an operation with no document named to the first one.
66
+ def spec_name
67
+ metadata[:openapi_spec] || metadata[:swagger_doc] || RSpec.configuration.openapi_specs.keys.first
68
+ end
69
+
70
+ # `q[search]` and the scopes go ahead of `page`, `sort` and the rest, so the
71
+ # filter a reader reaches for first is the first one listed.
72
+ def insert_before_query_parameters(parameters)
73
+ existing = metadata[:operation][:parameters] ||= []
74
+ index = existing.index { |parameter| parameter[:in] == :query } || existing.size
75
+ existing.insert(index, *parameters)
76
+ end
77
+
78
+ def register_filter_tables(tables)
79
+ spec = RSpec.configuration.openapi_specs.fetch(spec_name)
80
+ components = spec[:components] ||= {}
81
+ registered = components[FILTER_TABLES_KEY] || {}
82
+
83
+ tables.each do |name, table|
84
+ definition = table.to_h
85
+ if registered.key?(name) && registered[name] != definition
86
+ raise "Two filter tables are named #{name} in #{spec_name}; give one of the models a distinct api_type"
87
+ end
88
+
89
+ registered[name] = definition
90
+ end
91
+
92
+ # Sorted so the written spec does not depend on the order specs ran in.
93
+ components[FILTER_TABLES_KEY] = registered.sort.to_h
94
+ components[FILTER_PREDICATES_KEY] = Spree::Api::V3::FilterPredicates::BY_KIND
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,233 @@
1
+ require 'spree/api/testing_support/v3/base'
2
+ require 'spree/api/testing_support/agent_tools'
3
+ require 'spree/api/testing_support/deterministic_openapi'
4
+ require 'spree/api/testing_support/filter_parameters'
5
+ require 'spree/api/testing_support/sdk_method'
6
+
7
+ module Spree
8
+ module Api
9
+ module TestingSupport
10
+ # Documents a write endpoint's request body from the controller's
11
+ # `request_params` declaration, so no integration spec describes a body by hand.
12
+ #
13
+ # path '/api/v3/admin/products' do
14
+ # post 'Create a product' do
15
+ # request_body_for
16
+ #
17
+ # The body references the `<Resource>CreateParams` / `<Resource>UpdateParams`
18
+ # component, registered with every shared shape it uses under the spec's
19
+ # `components.schemas`. The SDK request types are generated from those
20
+ # components.
21
+ module RequestBody
22
+ # @param action [Symbol, nil] the controller action; read from the route
23
+ # when omitted
24
+ # @param controller [Class, nil] resolved from the path when omitted
25
+ # @param required [Boolean] false for an endpoint that accepts no body
26
+ def request_body_for(action = nil, controller: nil, required: true)
27
+ route = request_body_route
28
+ action ||= route[:action].to_sym
29
+ controller ||= "#{route[:controller]}_controller".camelize.constantize
30
+ schema = controller.request_params_schema(action)
31
+ raise "#{controller.name} does not declare request_params for #{action}" unless schema
32
+
33
+ name = schema.component_name(action)
34
+ register_request_schema(name, schema.json_schema(action.in?(%i[create update]) ? action : nil))
35
+ schema.shapes.each { |shape| register_request_schema(shape.name, shape.json_schema) }
36
+ schema.code_lists.each { |list_name, definition| register_request_schema(list_name, definition) }
37
+
38
+ parameter name: :body, in: :body, required: required, schema: { '$ref': "#/components/schemas/#{name}" }
39
+ end
40
+
41
+ # Documents a shape a read endpoint takes as query parameters, and publishes
42
+ # it as a component so the SDKs generate one type for it.
43
+ #
44
+ # @param shape [Spree::Api::V3::RequestSchema] a named shape
45
+ def query_parameters_for(shape)
46
+ register_request_schema(shape.name, shape.json_schema)
47
+ shape.shapes.each { |nested| register_request_schema(nested.name, nested.json_schema) }
48
+ shape.code_lists.each { |list_name, definition| register_request_schema(list_name, definition) }
49
+
50
+ shape.json_schema[:properties].each do |name, property|
51
+ parameter name: name, in: :query, required: false, description: property[:description],
52
+ schema: property.except(:description)
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def request_body_route
59
+ path = metadata[:path_item][:template].gsub(/\{[^}]+\}/, 'x1')
60
+ Rails.application.routes.recognize_path(path, method: metadata[:operation][:verb])
61
+ end
62
+
63
+ def register_request_schema(name, definition)
64
+ # rswag routes an operation with no document named to the first one.
65
+ specs = RSpec.configuration.openapi_specs
66
+ spec = specs.fetch(metadata[:openapi_spec] || metadata[:swagger_doc] || specs.keys.first)
67
+ schemas = (spec[:components] ||= {})[:schemas] ||= {}
68
+ definition = definition.deep_stringify_keys
69
+
70
+ if schemas.key?(name) && schemas[name].deep_stringify_keys != definition
71
+ raise "Two request schemas are named #{name}; " \
72
+ 'name one of the request_params declarations or shapes differently'
73
+ end
74
+
75
+ schemas[name] = definition
76
+ # Request schemas follow the response ones, sorted, so the written spec
77
+ # does not depend on the order specs ran in.
78
+ request, response = schemas.partition { |key, _| key.to_s.end_with?('Params') }
79
+ spec[:components][:schemas] = response.to_h.merge(request.sort_by { |key, _| key.to_s }.to_h)
80
+ end
81
+ end
82
+
83
+ # Appends a `**Required scope:**` line to the operation description, to
84
+ # surface scope requirements in the rendered docs without inventing a
85
+ # custom OpenAPI extension.
86
+ #
87
+ # admin_scope :read, :orders
88
+ # admin_scope :write, :customers
89
+ module AdminScope
90
+ def admin_scope(action, resource)
91
+ admin_scope_note("`#{action}_#{resource}`")
92
+ end
93
+
94
+ # Free-form variant for endpoints whose required scope is resolved at
95
+ # request time (e.g. exports — gated by the exported resource's scope).
96
+ def admin_scope_note(text)
97
+ line = "**Required scope:** #{text} (for API-key authentication)."
98
+ existing = metadata[:operation][:description].to_s
99
+ metadata[:operation][:description] = existing.empty? ? line : "#{existing}\n\n#{line}"
100
+ end
101
+ end
102
+
103
+ # What every admin operation in an rswag spec repeats, in one line: the
104
+ # content types, the two ways to authenticate, the scope note in the
105
+ # description, and a signed-in admin and secret key to call it with.
106
+ #
107
+ # patch 'Send a quote' do
108
+ # tags 'Quotes'
109
+ # admin_operation :write, :quotes, description: <<~DESC
110
+ # Sends the quote to the buyer. Use it once the prices are final.
111
+ # DESC
112
+ # request_body_for
113
+ #
114
+ # response('200', 'sent') { run_test! }
115
+ # end
116
+ #
117
+ # Pass the description here, or call it after `description`: the scope
118
+ # note is appended to whatever the description is at that point. A
119
+ # response block overrides either credential with its own `let`. The
120
+ # credentials come from the 'API v3 Admin' shared context, which
121
+ # Openapi.configure includes in every request spec.
122
+ module AdminOperation
123
+ BODY_VERBS = %i[post patch put].freeze
124
+
125
+ # @param kind [Symbol] :read or :write
126
+ # @param resource [Symbol] the controller's scoped resource
127
+ # @param description [String, nil]
128
+ # @return [void]
129
+ def admin_operation(kind, resource, description: nil)
130
+ verb = metadata[:operation][:verb].to_sym
131
+ consumes 'application/json' if BODY_VERBS.include?(verb)
132
+ produces 'application/json'
133
+ security [api_key: [], bearer_auth: []]
134
+ parameter name: 'x-spree-api-key', in: :header, type: :string, required: true
135
+ parameter name: :Authorization, in: :header, type: :string, required: true
136
+
137
+ self.description(description.strip) if description
138
+ admin_scope(kind, resource)
139
+
140
+ let(:'x-spree-api-key') { secret_api_key.plaintext_token }
141
+ let(:Authorization) { "Bearer #{admin_jwt_token}" }
142
+ end
143
+ end
144
+
145
+ # What an extension needs to generate the `openapi/admin.yaml` its gem
146
+ # ships, documenting its Admin API endpoints the way core documents its
147
+ # own, and with `store_title:` an `openapi/store.yaml` for its Store API
148
+ # endpoints. The extension can build its API docs or a client from the
149
+ # files (`spree sdk types`). In the extension's swagger helper:
150
+ #
151
+ # require 'rswag/specs'
152
+ # require 'spree/api/testing_support/openapi'
153
+ #
154
+ # RSpec.configure do |config|
155
+ # Spree::Api::TestingSupport::Openapi.configure(
156
+ # config, engine: MyExtension::Engine, title: 'My Extension Admin API'
157
+ # )
158
+ # end
159
+ #
160
+ # A Store API spec names its document and authenticates as a customer:
161
+ #
162
+ # path '/api/v3/store/quotes' do
163
+ # get 'List quotes', openapi_spec: 'openapi/store.yaml' do
164
+ # include_context 'API v3 Store authenticated'
165
+ #
166
+ # and in its Rakefile, `require 'spree/api/testing_support/openapi_tasks'`
167
+ # for `rake spree:openapi:generate`.
168
+ module Openapi
169
+ HELPERS = [RequestBody, FilterParameters, AdminScope, AdminOperation, AgentTools, SdkMethod].freeze
170
+
171
+ class << self
172
+ # @param config [RSpec::Core::Configuration]
173
+ # @param engine [Class<Rails::Engine>]
174
+ # @param title [String]
175
+ # @param description [String, nil]
176
+ # @param store_title [String, nil] also writes `openapi/store.yaml`,
177
+ # for an extension with Store API endpoints
178
+ # @return [void]
179
+ def configure(config, engine:, title:, description: nil, store_title: nil)
180
+ config.openapi_root = engine.root.to_s
181
+ # Admin first: rswag writes an operation that names no document there.
182
+ config.openapi_specs = { 'openapi/admin.yaml' => admin_document(title, description) }
183
+ config.openapi_specs['openapi/store.yaml'] = store_document(store_title) if store_title
184
+ config.openapi_format = :yaml
185
+ HELPERS.each { |helper| config.extend helper, type: :request }
186
+ # The admin and secret key admin_operation authenticates with.
187
+ config.include_context 'API v3 Admin', type: :request
188
+ end
189
+
190
+ # The skeleton of an extension's store document: the same security
191
+ # schemes as core's Store API.
192
+ #
193
+ # @return [Hash]
194
+ def store_document(title)
195
+ {
196
+ openapi: '3.0.3',
197
+ info: { title: title, version: 'v3' },
198
+ paths: {},
199
+ components: {
200
+ securitySchemes: {
201
+ api_key: { type: :apiKey, name: 'x-spree-api-key', in: :header,
202
+ description: 'Publishable API key for store access' },
203
+ bearer_auth: { type: :http, scheme: :bearer, bearerFormat: 'JWT',
204
+ description: 'JWT token for authenticated customers' }
205
+ }
206
+ }
207
+ }
208
+ end
209
+
210
+ # The skeleton of an extension's admin document: the same security
211
+ # schemes as core's, so its operations authenticate the same way.
212
+ #
213
+ # @return [Hash]
214
+ def admin_document(title, description = nil)
215
+ {
216
+ openapi: '3.0.3',
217
+ info: { title: title, version: 'v3', description: description }.compact,
218
+ paths: {},
219
+ components: {
220
+ securitySchemes: {
221
+ api_key: { type: :apiKey, name: 'x-spree-api-key', in: :header,
222
+ description: 'Secret API key for admin access' },
223
+ bearer_auth: { type: :http, scheme: :bearer, bearerFormat: 'JWT',
224
+ description: 'JWT token for admin user authentication' }
225
+ }
226
+ }
227
+ }
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,21 @@
1
+ # `rake spree:openapi:generate` for an extension or a Spree application: runs
2
+ # its integration specs through rswag and writes the `openapi/admin.yaml` it
3
+ # ships, and `openapi/store.yaml` when it documents Store API endpoints. Under
4
+ # OPENAPI=true the run is reproducible (see deterministic_openapi.rb), so a
5
+ # diff in a file means the API changed.
6
+ #
7
+ # RSpec is started only when the task runs, so a Rakefile that loads this
8
+ # keeps working in a bundle without rspec-core. Defined once, however many
9
+ # times it is loaded: a second definition would run the specs twice.
10
+ unless Rake::Task.task_defined?('spree:openapi:generate')
11
+ namespace :spree do
12
+ namespace :openapi do
13
+ desc 'Generate the openapi/*.yaml documents from the integration specs here'
14
+ task :generate do
15
+ pattern = ENV.fetch('PATTERN', 'spec/integration/**/*_spec.rb')
16
+ ENV['OPENAPI'] = 'true'
17
+ ruby '-S', 'rspec', '--pattern', pattern, '--format', 'Rswag::Specs::SwaggerFormatter', '--order', 'defined'
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,34 @@
1
+ module Spree
2
+ module Api
3
+ module TestingSupport
4
+ # rswag helper for what an operation is called in the SDK client
5
+ # `spree sdk generate` writes for an extension. By default a method is
6
+ # named after the path (`PATCH /quotes/{id}/send` → `quotes.send`), and
7
+ # an `operationId` renames it, but the `operationId` also names the
8
+ # operation's agent tool. This names the SDK method alone:
9
+ #
10
+ # delete 'Remove a file from a quote' do
11
+ # sdk_method :remove_attachment # quotes.removeAttachment
12
+ # end
13
+ #
14
+ # post 'Upload a file for a quote request' do
15
+ # sdk_method :presign_attachment, namespace: :quotes # not carts.…
16
+ # end
17
+ #
18
+ # See docs/plans/6.0-sdk-extensions.md.
19
+ module SdkMethod
20
+ METHOD_KEY = :'x-spree-sdk-method'
21
+ NAMESPACE_KEY = :'x-spree-sdk-namespace'
22
+
23
+ # @param name [Symbol, String] the method, snake_case or camelCase
24
+ # @param namespace [Symbol, String, nil] the client namespace it goes in,
25
+ # instead of the path's resource
26
+ # @return [void]
27
+ def sdk_method(name, namespace: nil)
28
+ metadata[:operation][METHOD_KEY] = name.to_s
29
+ metadata[:operation][NAMESPACE_KEY] = namespace.to_s if namespace
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end