spree_api 6.0.2 → 6.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/controllers/concerns/spree/api/v3/admin/embed_permissions.rb +5 -2
- data/app/controllers/spree/api/v3/admin/products_controller.rb +56 -0
- data/app/serializers/spree/api/v3/admin/admin_user_serializer.rb +2 -2
- data/app/serializers/spree/api/v3/admin/delivery_profile_serializer.rb +2 -1
- data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/import_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/invitation_serializer.rb +2 -2
- data/app/serializers/spree/api/v3/admin/price_rule_serializer.rb +3 -1
- data/app/serializers/spree/api/v3/admin/product_serializer.rb +8 -0
- data/app/serializers/spree/api/v3/admin/promotion_action_serializer.rb +2 -2
- data/app/serializers/spree/api/v3/admin/promotion_rule_serializer.rb +4 -1
- data/app/serializers/spree/api/v3/admin/promotion_serializer.rb +13 -0
- data/app/serializers/spree/api/v3/admin/return_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/seller_requirement_status_serializer.rb +3 -2
- data/app/serializers/spree/api/v3/admin/seller_serializer.rb +2 -2
- data/app/serializers/spree/api/v3/fulfillment_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/seller/import_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/seller/invitation_serializer.rb +2 -2
- data/app/serializers/spree/api/v3/seller/product_serializer.rb +8 -0
- data/app/serializers/spree/api/v3/seller/requirement_status_serializer.rb +3 -2
- data/config/initializers/typelizer.rb +8 -19
- data/config/routes.rb +5 -0
- data/lib/spree/api/admin_document.rb +284 -0
- data/lib/spree/api/agent_operations.rb +300 -0
- data/lib/spree/api/agent_resource_map.rb +32 -7
- data/lib/spree/api/agent_write_schemas.rb +15 -51
- data/lib/spree/api/engine.rb +14 -1
- data/lib/spree/api/enum_types.rb +4 -1
- data/lib/spree/api/openapi/schema_helper.rb +3 -3
- data/lib/spree/api/sdk_types.rb +190 -0
- data/lib/spree/api/sdk_types_task.rb +68 -0
- data/lib/spree/api/testing_support/agent_tools.rb +35 -0
- data/lib/spree/api/testing_support/agent_tools_contract.rb +110 -0
- data/lib/spree/api/testing_support/deterministic_openapi.rb +147 -0
- data/lib/spree/api/testing_support/filter_parameters.rb +99 -0
- data/lib/spree/api/testing_support/openapi.rb +232 -0
- data/lib/spree/api/testing_support/openapi_tasks.rb +21 -0
- data/lib/spree/api/typelizer_generation.rb +39 -11
- data/lib/tasks/openapi.rake +5 -0
- data/lib/tasks/sdk_types.rake +6 -0
- data/lib/typelizer/open_enums.rb +13 -1
- data/openapi/admin.yaml +1540 -33
- metadata +20 -9
- data/lib/spree/api/agent_workflow_endpoints.rb +0 -113
|
@@ -0,0 +1,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,232 @@
|
|
|
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
|
+
|
|
6
|
+
module Spree
|
|
7
|
+
module Api
|
|
8
|
+
module TestingSupport
|
|
9
|
+
# Documents a write endpoint's request body from the controller's
|
|
10
|
+
# `request_params` declaration, so no integration spec describes a body by hand.
|
|
11
|
+
#
|
|
12
|
+
# path '/api/v3/admin/products' do
|
|
13
|
+
# post 'Create a product' do
|
|
14
|
+
# request_body_for
|
|
15
|
+
#
|
|
16
|
+
# The body references the `<Resource>CreateParams` / `<Resource>UpdateParams`
|
|
17
|
+
# component, registered with every shared shape it uses under the spec's
|
|
18
|
+
# `components.schemas`. The SDK request types are generated from those
|
|
19
|
+
# components.
|
|
20
|
+
module RequestBody
|
|
21
|
+
# @param action [Symbol, nil] the controller action; read from the route
|
|
22
|
+
# when omitted
|
|
23
|
+
# @param controller [Class, nil] resolved from the path when omitted
|
|
24
|
+
# @param required [Boolean] false for an endpoint that accepts no body
|
|
25
|
+
def request_body_for(action = nil, controller: nil, required: true)
|
|
26
|
+
route = request_body_route
|
|
27
|
+
action ||= route[:action].to_sym
|
|
28
|
+
controller ||= "#{route[:controller]}_controller".camelize.constantize
|
|
29
|
+
schema = controller.request_params_schema(action)
|
|
30
|
+
raise "#{controller.name} does not declare request_params for #{action}" unless schema
|
|
31
|
+
|
|
32
|
+
name = schema.component_name(action)
|
|
33
|
+
register_request_schema(name, schema.json_schema(action.in?(%i[create update]) ? action : nil))
|
|
34
|
+
schema.shapes.each { |shape| register_request_schema(shape.name, shape.json_schema) }
|
|
35
|
+
schema.code_lists.each { |list_name, definition| register_request_schema(list_name, definition) }
|
|
36
|
+
|
|
37
|
+
parameter name: :body, in: :body, required: required, schema: { '$ref': "#/components/schemas/#{name}" }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Documents a shape a read endpoint takes as query parameters, and publishes
|
|
41
|
+
# it as a component so the SDKs generate one type for it.
|
|
42
|
+
#
|
|
43
|
+
# @param shape [Spree::Api::V3::RequestSchema] a named shape
|
|
44
|
+
def query_parameters_for(shape)
|
|
45
|
+
register_request_schema(shape.name, shape.json_schema)
|
|
46
|
+
shape.shapes.each { |nested| register_request_schema(nested.name, nested.json_schema) }
|
|
47
|
+
shape.code_lists.each { |list_name, definition| register_request_schema(list_name, definition) }
|
|
48
|
+
|
|
49
|
+
shape.json_schema[:properties].each do |name, property|
|
|
50
|
+
parameter name: name, in: :query, required: false, description: property[:description],
|
|
51
|
+
schema: property.except(:description)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def request_body_route
|
|
58
|
+
path = metadata[:path_item][:template].gsub(/\{[^}]+\}/, 'x1')
|
|
59
|
+
Rails.application.routes.recognize_path(path, method: metadata[:operation][:verb])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def register_request_schema(name, definition)
|
|
63
|
+
# rswag routes an operation with no document named to the first one.
|
|
64
|
+
specs = RSpec.configuration.openapi_specs
|
|
65
|
+
spec = specs.fetch(metadata[:openapi_spec] || metadata[:swagger_doc] || specs.keys.first)
|
|
66
|
+
schemas = (spec[:components] ||= {})[:schemas] ||= {}
|
|
67
|
+
definition = definition.deep_stringify_keys
|
|
68
|
+
|
|
69
|
+
if schemas.key?(name) && schemas[name].deep_stringify_keys != definition
|
|
70
|
+
raise "Two request schemas are named #{name}; " \
|
|
71
|
+
'name one of the request_params declarations or shapes differently'
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
schemas[name] = definition
|
|
75
|
+
# Request schemas follow the response ones, sorted, so the written spec
|
|
76
|
+
# does not depend on the order specs ran in.
|
|
77
|
+
request, response = schemas.partition { |key, _| key.to_s.end_with?('Params') }
|
|
78
|
+
spec[:components][:schemas] = response.to_h.merge(request.sort_by { |key, _| key.to_s }.to_h)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Appends a `**Required scope:**` line to the operation description, to
|
|
83
|
+
# surface scope requirements in the rendered docs without inventing a
|
|
84
|
+
# custom OpenAPI extension.
|
|
85
|
+
#
|
|
86
|
+
# admin_scope :read, :orders
|
|
87
|
+
# admin_scope :write, :customers
|
|
88
|
+
module AdminScope
|
|
89
|
+
def admin_scope(action, resource)
|
|
90
|
+
admin_scope_note("`#{action}_#{resource}`")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Free-form variant for endpoints whose required scope is resolved at
|
|
94
|
+
# request time (e.g. exports — gated by the exported resource's scope).
|
|
95
|
+
def admin_scope_note(text)
|
|
96
|
+
line = "**Required scope:** #{text} (for API-key authentication)."
|
|
97
|
+
existing = metadata[:operation][:description].to_s
|
|
98
|
+
metadata[:operation][:description] = existing.empty? ? line : "#{existing}\n\n#{line}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# What every admin operation in an rswag spec repeats, in one line: the
|
|
103
|
+
# content types, the two ways to authenticate, the scope note in the
|
|
104
|
+
# description, and a signed-in admin and secret key to call it with.
|
|
105
|
+
#
|
|
106
|
+
# patch 'Send a quote' do
|
|
107
|
+
# tags 'Quotes'
|
|
108
|
+
# admin_operation :write, :quotes, description: <<~DESC
|
|
109
|
+
# Sends the quote to the buyer. Use it once the prices are final.
|
|
110
|
+
# DESC
|
|
111
|
+
# request_body_for
|
|
112
|
+
#
|
|
113
|
+
# response('200', 'sent') { run_test! }
|
|
114
|
+
# end
|
|
115
|
+
#
|
|
116
|
+
# Pass the description here, or call it after `description`: the scope
|
|
117
|
+
# note is appended to whatever the description is at that point. A
|
|
118
|
+
# response block overrides either credential with its own `let`. The
|
|
119
|
+
# credentials come from the 'API v3 Admin' shared context, which
|
|
120
|
+
# Openapi.configure includes in every request spec.
|
|
121
|
+
module AdminOperation
|
|
122
|
+
BODY_VERBS = %i[post patch put].freeze
|
|
123
|
+
|
|
124
|
+
# @param kind [Symbol] :read or :write
|
|
125
|
+
# @param resource [Symbol] the controller's scoped resource
|
|
126
|
+
# @param description [String, nil]
|
|
127
|
+
# @return [void]
|
|
128
|
+
def admin_operation(kind, resource, description: nil)
|
|
129
|
+
verb = metadata[:operation][:verb].to_sym
|
|
130
|
+
consumes 'application/json' if BODY_VERBS.include?(verb)
|
|
131
|
+
produces 'application/json'
|
|
132
|
+
security [api_key: [], bearer_auth: []]
|
|
133
|
+
parameter name: 'x-spree-api-key', in: :header, type: :string, required: true
|
|
134
|
+
parameter name: :Authorization, in: :header, type: :string, required: true
|
|
135
|
+
|
|
136
|
+
self.description(description.strip) if description
|
|
137
|
+
admin_scope(kind, resource)
|
|
138
|
+
|
|
139
|
+
let(:'x-spree-api-key') { secret_api_key.plaintext_token }
|
|
140
|
+
let(:Authorization) { "Bearer #{admin_jwt_token}" }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# What an extension needs to generate the `openapi/admin.yaml` its gem
|
|
145
|
+
# ships, documenting its Admin API endpoints the way core documents its
|
|
146
|
+
# own, and with `store_title:` an `openapi/store.yaml` for its Store API
|
|
147
|
+
# endpoints. The extension can build its API docs or a client from the
|
|
148
|
+
# files (`spree sdk types`). In the extension's swagger helper:
|
|
149
|
+
#
|
|
150
|
+
# require 'rswag/specs'
|
|
151
|
+
# require 'spree/api/testing_support/openapi'
|
|
152
|
+
#
|
|
153
|
+
# RSpec.configure do |config|
|
|
154
|
+
# Spree::Api::TestingSupport::Openapi.configure(
|
|
155
|
+
# config, engine: MyExtension::Engine, title: 'My Extension Admin API'
|
|
156
|
+
# )
|
|
157
|
+
# end
|
|
158
|
+
#
|
|
159
|
+
# A Store API spec names its document and authenticates as a customer:
|
|
160
|
+
#
|
|
161
|
+
# path '/api/v3/store/quotes' do
|
|
162
|
+
# get 'List quotes', openapi_spec: 'openapi/store.yaml' do
|
|
163
|
+
# include_context 'API v3 Store authenticated'
|
|
164
|
+
#
|
|
165
|
+
# and in its Rakefile, `require 'spree/api/testing_support/openapi_tasks'`
|
|
166
|
+
# for `rake spree:openapi:generate`.
|
|
167
|
+
module Openapi
|
|
168
|
+
HELPERS = [RequestBody, FilterParameters, AdminScope, AdminOperation, AgentTools].freeze
|
|
169
|
+
|
|
170
|
+
class << self
|
|
171
|
+
# @param config [RSpec::Core::Configuration]
|
|
172
|
+
# @param engine [Class<Rails::Engine>]
|
|
173
|
+
# @param title [String]
|
|
174
|
+
# @param description [String, nil]
|
|
175
|
+
# @param store_title [String, nil] also writes `openapi/store.yaml`,
|
|
176
|
+
# for an extension with Store API endpoints
|
|
177
|
+
# @return [void]
|
|
178
|
+
def configure(config, engine:, title:, description: nil, store_title: nil)
|
|
179
|
+
config.openapi_root = engine.root.to_s
|
|
180
|
+
# Admin first: rswag writes an operation that names no document there.
|
|
181
|
+
config.openapi_specs = { 'openapi/admin.yaml' => admin_document(title, description) }
|
|
182
|
+
config.openapi_specs['openapi/store.yaml'] = store_document(store_title) if store_title
|
|
183
|
+
config.openapi_format = :yaml
|
|
184
|
+
HELPERS.each { |helper| config.extend helper, type: :request }
|
|
185
|
+
# The admin and secret key admin_operation authenticates with.
|
|
186
|
+
config.include_context 'API v3 Admin', type: :request
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The skeleton of an extension's store document: the same security
|
|
190
|
+
# schemes as core's Store API.
|
|
191
|
+
#
|
|
192
|
+
# @return [Hash]
|
|
193
|
+
def store_document(title)
|
|
194
|
+
{
|
|
195
|
+
openapi: '3.0.3',
|
|
196
|
+
info: { title: title, version: 'v3' },
|
|
197
|
+
paths: {},
|
|
198
|
+
components: {
|
|
199
|
+
securitySchemes: {
|
|
200
|
+
api_key: { type: :apiKey, name: 'x-spree-api-key', in: :header,
|
|
201
|
+
description: 'Publishable API key for store access' },
|
|
202
|
+
bearer_auth: { type: :http, scheme: :bearer, bearerFormat: 'JWT',
|
|
203
|
+
description: 'JWT token for authenticated customers' }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The skeleton of an extension's admin document: the same security
|
|
210
|
+
# schemes as core's, so its operations authenticate the same way.
|
|
211
|
+
#
|
|
212
|
+
# @return [Hash]
|
|
213
|
+
def admin_document(title, description = nil)
|
|
214
|
+
{
|
|
215
|
+
openapi: '3.0.3',
|
|
216
|
+
info: { title: title, version: 'v3', description: description }.compact,
|
|
217
|
+
paths: {},
|
|
218
|
+
components: {
|
|
219
|
+
securitySchemes: {
|
|
220
|
+
api_key: { type: :apiKey, name: 'x-spree-api-key', in: :header,
|
|
221
|
+
description: 'Secret API key for admin access' },
|
|
222
|
+
bearer_auth: { type: :http, scheme: :bearer, bearerFormat: 'JWT',
|
|
223
|
+
description: 'JWT token for admin user authentication' }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
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
|
|
@@ -5,6 +5,44 @@ module Spree
|
|
|
5
5
|
# place where the API's and the email serializers are all loaded: running
|
|
6
6
|
# it with any serializer missing would delete that serializer's types.
|
|
7
7
|
module TypelizerGeneration
|
|
8
|
+
# Serializers that exist only for the Admin API, kept out of the Store
|
|
9
|
+
# SDK. Event serializers stay in: they describe the `data` of the typed
|
|
10
|
+
# webhook events the Store SDK ships.
|
|
11
|
+
STORE_EXCLUDED = %w[Asset CartPromotion OrderPromotion Report TaxCategory].to_set.freeze
|
|
12
|
+
|
|
13
|
+
# The store-level types the Seller SDK carries because a seller
|
|
14
|
+
# serializer nests them (see the seller writer in the initializer).
|
|
15
|
+
SELLER_STORE_TYPES = %w[Category CustomField OptionType OptionValue Seller].to_set.freeze
|
|
16
|
+
|
|
17
|
+
# Typelizer's Alba plugin, taught the custom Alba types (see alba.rb);
|
|
18
|
+
# unmapped typed attributes crash generation.
|
|
19
|
+
#
|
|
20
|
+
# @return [Hash] Typelizer's `plugin_configs`
|
|
21
|
+
def self.plugin_configs
|
|
22
|
+
{
|
|
23
|
+
alba: {
|
|
24
|
+
ts_mapper: Typelizer::SerializerPlugins::Alba::ALBA_TS_MAPPER.merge(
|
|
25
|
+
'iso8601' => { type: :string },
|
|
26
|
+
'string' => { type: :string }
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# `interface` rather than `type`, so SDK consumers can extend generated
|
|
33
|
+
# types through declaration merging.
|
|
34
|
+
#
|
|
35
|
+
# @param dir [String, Pathname] a folder Typelizer wrote
|
|
36
|
+
def self.use_interfaces!(dir)
|
|
37
|
+
Dir[File.join(dir, '*.ts')].each do |file|
|
|
38
|
+
next if File.basename(file) == 'index.ts'
|
|
39
|
+
|
|
40
|
+
content = File.read(file)
|
|
41
|
+
updated = content.gsub(/^type (\w+) = \{/, 'interface \1 {')
|
|
42
|
+
File.write(file, updated) if updated != content
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
8
46
|
# @param serializer_dirs [Array<String, Pathname>] directories to load before generating
|
|
9
47
|
def self.call(serializer_dirs)
|
|
10
48
|
serializer_dirs.each { |dir| Rails.autoloaders.main.eager_load_dir(dir.to_s) }
|
|
@@ -17,19 +55,9 @@ module Spree
|
|
|
17
55
|
require 'typelizer/generator'
|
|
18
56
|
Typelizer::Generator.call(force: true)
|
|
19
57
|
|
|
20
|
-
# `interface` rather than `type`, so SDK consumers can extend generated
|
|
21
|
-
# types through declaration merging.
|
|
22
58
|
api_root = Spree::Api::Engine.root
|
|
23
59
|
[api_root.join('../../packages/sdk/src/types/generated'), api_root.join('../../packages/admin-sdk/src/types/generated'),
|
|
24
|
-
api_root.join('../../packages/admin-sdk/src/types/generated/emails')].each
|
|
25
|
-
Dir[File.join(dir, '*.ts')].each do |file|
|
|
26
|
-
next if File.basename(file) == 'index.ts'
|
|
27
|
-
|
|
28
|
-
content = File.read(file)
|
|
29
|
-
updated = content.gsub(/^type (\w+) = \{/, 'interface \1 {')
|
|
30
|
-
File.write(file, updated) if updated != content
|
|
31
|
-
end
|
|
32
|
-
end
|
|
60
|
+
api_root.join('../../packages/admin-sdk/src/types/generated/emails')].each { |dir| use_interfaces!(dir) }
|
|
33
61
|
|
|
34
62
|
# The typed webhook events in @spree/sdk, from the event catalog.
|
|
35
63
|
require Spree::Api::Engine.root.join('lib/spree/api/webhook_event_types').to_s
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# `spree task openapi:generate` (`bin/rails spree:openapi:generate`) in a Spree
|
|
2
|
+
# application: writes openapi/admin.yaml from the app's own rswag specs, so the
|
|
3
|
+
# admin endpoints it adds are documented, and each documented action is an
|
|
4
|
+
# agent tool. Extensions get the same task from extension_rake.
|
|
5
|
+
load File.expand_path('../spree/api/testing_support/openapi_tasks.rb', __dir__)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# `spree task sdk:serializer_types OUT=…` (`bin/rails spree:sdk:serializer_types`)
|
|
2
|
+
# in a Spree application: writes the response types of a package that extends
|
|
3
|
+
# the SDKs for the app's own endpoints, from the app's serializers. Extensions
|
|
4
|
+
# get the same task from extension_rake.
|
|
5
|
+
require 'spree/api/sdk_types_task'
|
|
6
|
+
Spree::Api::SdkTypesTask.new
|
data/lib/typelizer/open_enums.rb
CHANGED
|
@@ -19,6 +19,14 @@ module Typelizer
|
|
|
19
19
|
# the standard can add a country, a currency or a unit, and markets only
|
|
20
20
|
# accept the locales `Spree::Locales::ALL` lists.
|
|
21
21
|
module OpenEnums
|
|
22
|
+
class << self
|
|
23
|
+
# Where a named list's type is imported from. Spree's own SDKs re-export
|
|
24
|
+
# it from `@spree/sdk-core`; an extension's package (Spree::Api::SdkTypes)
|
|
25
|
+
# sets this to nil and defines its lists itself, open unless closed.
|
|
26
|
+
attr_accessor :source
|
|
27
|
+
end
|
|
28
|
+
self.source = '@spree/sdk-core'
|
|
29
|
+
|
|
22
30
|
CLOSED_TYPE_NAMES = %w[CountryCode CurrencyCode LocaleCode WeightUnit DimensionUnit].freeze
|
|
23
31
|
|
|
24
32
|
CLOSED_LIST_NAMES = %w[
|
|
@@ -61,7 +69,11 @@ module Typelizer
|
|
|
61
69
|
|
|
62
70
|
module Property
|
|
63
71
|
def enum_definition(...)
|
|
64
|
-
|
|
72
|
+
definition = super
|
|
73
|
+
return definition unless definition
|
|
74
|
+
return "type { #{enum_type_name} } from '#{OpenEnums.source}'" if OpenEnums.source
|
|
75
|
+
|
|
76
|
+
OpenEnums.closed?(enum_type_name) ? definition : "#{definition} | (string & {})"
|
|
65
77
|
end
|
|
66
78
|
|
|
67
79
|
# Typelizer skips rewriting a file whose digest is unchanged, and the
|