spree_api 6.0.0.rc2 → 6.0.0.rc4
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/email_template_lookup.rb +6 -1
- data/app/controllers/concerns/spree/api/v3/admin/reporting_authorization.rb +17 -8
- data/app/controllers/concerns/spree/api/v3/admin/store_context.rb +7 -0
- data/app/controllers/concerns/spree/api/v3/admin_authentication.rb +61 -0
- data/app/controllers/concerns/spree/api/v3/error_handler.rb +49 -0
- data/app/controllers/concerns/spree/api/v3/params_normalizer.rb +76 -7
- data/app/controllers/concerns/spree/api/v3/scoped_authorization.rb +60 -3
- data/app/controllers/concerns/spree/api/v3/store/search_provider_support.rb +8 -3
- data/app/controllers/spree/api/v3/admin/companies/tax_exemption_certificates_controller.rb +11 -5
- data/app/controllers/spree/api/v3/admin/concerns/external_references.rb +3 -0
- data/app/controllers/spree/api/v3/admin/exports_controller.rb +15 -3
- data/app/controllers/spree/api/v3/admin/imports_controller.rb +12 -3
- data/app/controllers/spree/api/v3/admin/markets_controller.rb +3 -1
- data/app/controllers/spree/api/v3/admin/me_controller.rb +21 -1
- data/app/controllers/spree/api/v3/admin/oauth/application_tokens_controller.rb +56 -0
- data/app/controllers/spree/api/v3/admin/oauth/applications_controller.rb +55 -0
- data/app/controllers/spree/api/v3/admin/oauth/authorizations_controller.rb +294 -0
- data/app/controllers/spree/api/v3/admin/order_groups_controller.rb +1 -1
- data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +8 -0
- data/app/controllers/spree/api/v3/admin/orders/items_controller.rb +1 -0
- data/app/controllers/spree/api/v3/admin/orders/returns_controller.rb +7 -0
- data/app/controllers/spree/api/v3/admin/orders_controller.rb +9 -2
- data/app/controllers/spree/api/v3/admin/purchase_orders_controller.rb +1 -0
- data/app/controllers/spree/api/v3/admin/reporting_controller.rb +20 -14
- data/app/controllers/spree/api/v3/admin/stock_levels_controller.rb +1 -1
- data/app/controllers/spree/api/v3/admin/tags_controller.rb +8 -5
- data/app/controllers/spree/api/v3/admin/tax_exemption_reasons_controller.rb +58 -0
- data/app/controllers/spree/api/v3/admin/tax_rates_controller.rb +0 -1
- data/app/controllers/spree/api/v3/admin/translations/batches_controller.rb +17 -11
- data/app/controllers/spree/api/v3/oauth/authorizations_controller.rb +61 -0
- data/app/controllers/spree/api/v3/oauth/metadata_controller.rb +57 -0
- data/app/controllers/spree/api/v3/oauth/server_metadata_controller.rb +57 -0
- data/app/controllers/spree/api/v3/resource_controller.rb +1 -0
- data/app/controllers/spree/api/v3/store/carts/store_credits_controller.rb +13 -1
- data/app/controllers/spree/api/v3/store/carts_controller.rb +4 -7
- data/app/request_schemas/spree/api/v3/admin/email_template_preview_request_schema.rb +4 -0
- data/app/request_schemas/spree/api/v3/store/address_request_schema.rb +2 -0
- data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +2 -0
- data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +2 -0
- data/app/serializers/spree/api/v3/admin/oauth_access_token_serializer.rb +62 -0
- data/app/serializers/spree/api/v3/admin/oauth_application_serializer.rb +56 -0
- data/app/serializers/spree/api/v3/admin/oauth_consent_request_serializer.rb +25 -0
- data/app/serializers/spree/api/v3/admin/oauth_protected_resource_serializer.rb +27 -0
- data/app/serializers/spree/api/v3/admin/oauth_redirect_serializer.rb +17 -0
- data/app/serializers/spree/api/v3/admin/payment_capture_event_serializer.rb +16 -0
- data/app/serializers/spree/api/v3/admin/payment_serializer.rb +12 -1
- data/app/serializers/spree/api/v3/admin/price_history_serializer.rb +1 -0
- data/app/serializers/spree/api/v3/admin/price_serializer.rb +2 -0
- data/app/serializers/spree/api/v3/admin/return_serializer.rb +2 -0
- data/app/serializers/spree/api/v3/admin/tax_exemption_certificate_serializer.rb +7 -3
- data/app/serializers/spree/api/v3/admin/tax_exemption_reason_serializer.rb +20 -0
- data/app/serializers/spree/api/v3/admin/tax_rate_serializer.rb +1 -2
- data/app/serializers/spree/api/v3/admin/webhook_delivery_serializer.rb +4 -1
- data/app/serializers/spree/api/v3/base_serializer.rb +14 -0
- data/app/serializers/spree/api/v3/cart_serializer.rb +5 -1
- data/app/serializers/spree/api/v3/price_history_serializer.rb +6 -1
- data/app/serializers/spree/api/v3/price_serializer.rb +11 -1
- data/app/serializers/spree/api/v3/seller/price_history_serializer.rb +1 -0
- data/app/serializers/spree/api/v3/seller/price_serializer.rb +2 -0
- data/config/routes.rb +61 -1
- data/lib/spree/api/agent_membership_map.rb +73 -0
- data/lib/spree/api/agent_resource_map.rb +350 -0
- data/lib/spree/api/agent_workflow_endpoints.rb +113 -0
- data/lib/spree/api/agent_write_schemas.rb +119 -0
- data/lib/spree/api/dependencies.rb +4 -3
- data/lib/spree/api/engine.rb +40 -0
- data/lib/spree/api/extension_fields.rb +167 -0
- data/lib/spree/api/oauth.rb +243 -0
- data/lib/spree/api.rb +8 -0
- data/lib/typelizer/open_enums.rb +17 -3
- metadata +26 -6
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
# Which Admin API operation runs each workflow, derived rather than listed.
|
|
4
|
+
#
|
|
5
|
+
# Walk the admin routes; for each one, read the controller source and ask
|
|
6
|
+
# whether the action behind it calls that workflow. The verb and path come
|
|
7
|
+
# from the route, and the body keys from the generated OpenAPI document —
|
|
8
|
+
# which is the only place that records a rename (a cancellation's
|
|
9
|
+
# `reason:` keyword travels as `cancel_reason_id`).
|
|
10
|
+
#
|
|
11
|
+
# Read off the controllers for the same reason {AgentResourceMap} is: a
|
|
12
|
+
# workflow that gains an endpoint is dispatched without anyone editing a
|
|
13
|
+
# file, and one whose endpoint goes away stops being dispatched.
|
|
14
|
+
module AgentWorkflowEndpoints
|
|
15
|
+
# Where a controller declares a workflow for the base class to run,
|
|
16
|
+
# rather than calling it inside an action of its own.
|
|
17
|
+
DECLARED_IN = { 'create_workflow' => 'create', 'update_workflow' => 'update' }.freeze
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# @param map [Module]
|
|
21
|
+
# @return [Integer] how many workflows were mapped
|
|
22
|
+
def install(map: Spree::AgentTools::WorkflowEndpoints)
|
|
23
|
+
map.reset!
|
|
24
|
+
keys = Spree::AgentTools::DefaultCatalog::WORKFLOWS.keys
|
|
25
|
+
document = openapi_document
|
|
26
|
+
|
|
27
|
+
admin_routes.each do |route|
|
|
28
|
+
source = controller_source(route[:controller])
|
|
29
|
+
next if source.nil?
|
|
30
|
+
|
|
31
|
+
keys.each do |key|
|
|
32
|
+
# `find` would derive, and deriving is what this is — read the
|
|
33
|
+
# entries already registered instead.
|
|
34
|
+
next if map.registered.key?(key)
|
|
35
|
+
next unless action_calling(source, key) == route[:action]
|
|
36
|
+
|
|
37
|
+
map.register(key: key, verb: route[:verb], path: route[:path],
|
|
38
|
+
body_keys: body_keys_for(document, route))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
map.registered.size
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def admin_routes
|
|
48
|
+
Spree::Core::Engine.routes.routes.filter_map do |route|
|
|
49
|
+
controller = route.defaults[:controller].to_s
|
|
50
|
+
next unless controller.start_with?('spree/api/v3/admin')
|
|
51
|
+
|
|
52
|
+
{ controller: controller, action: route.defaults[:action].to_s,
|
|
53
|
+
verb: route.verb.to_s, path: route.path.spec.to_s.sub('(.:format)', '') }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Which action calls this workflow: the `def` nearest above the call.
|
|
58
|
+
# A declaration consumed by the base class names the action it serves
|
|
59
|
+
# instead.
|
|
60
|
+
def action_calling(source, key)
|
|
61
|
+
index = source.index("Spree.#{key}")
|
|
62
|
+
return if index.nil?
|
|
63
|
+
|
|
64
|
+
enclosing = source[0...index].scan(/^\s+def ([a-z_]+)/).flatten.last
|
|
65
|
+
DECLARED_IN.fetch(enclosing, enclosing)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def controller_source(controller_path)
|
|
69
|
+
@sources ||= {}
|
|
70
|
+
return @sources[controller_path] if @sources.key?(controller_path)
|
|
71
|
+
|
|
72
|
+
@sources[controller_path] = begin
|
|
73
|
+
klass = "#{controller_path.camelize}Controller".safe_constantize
|
|
74
|
+
file = klass && Object.const_source_location(klass.name)&.first
|
|
75
|
+
file && File.exist?(file) ? File.read(file) : nil
|
|
76
|
+
rescue StandardError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# What the operation's request body accepts, in the names the wire
|
|
82
|
+
# uses. A workflow keyword and its body key often differ, and the
|
|
83
|
+
# document is where that difference is recorded.
|
|
84
|
+
def body_keys_for(document, route)
|
|
85
|
+
operation = document.dig('paths', openapi_path(route[:path]), route[:verb].downcase)
|
|
86
|
+
schema = operation&.dig('requestBody', 'content', 'application/json', 'schema')
|
|
87
|
+
# Bodies are declared once and published as components, so the
|
|
88
|
+
# operation points at one rather than carrying its properties.
|
|
89
|
+
schema = Spree::Api::AgentWriteSchemas.resolve_schema(schema)
|
|
90
|
+
|
|
91
|
+
(schema&.dig('properties') || {}).keys
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def openapi_path(path)
|
|
95
|
+
path.gsub(/:([a-z_]+)/) { "{#{Regexp.last_match(1)}}" }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A packaged installation has no docs/ directory, and a body the
|
|
99
|
+
# document does not describe is simply passed through unchecked.
|
|
100
|
+
#
|
|
101
|
+
# `AgentWriteSchemas` already holds this file parsed, so it is read
|
|
102
|
+
# through there rather than a second time: the document is nearly a
|
|
103
|
+
# megabyte, and parsing it twice at boot is a second of nothing.
|
|
104
|
+
def openapi_document
|
|
105
|
+
Spree::Api::AgentWriteSchemas.document
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
Rails.logger.warn("[Spree] could not read the admin API document: #{e.class}: #{e.message}")
|
|
108
|
+
{}
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
# The writable attribute schema for each admin resource, read from the
|
|
4
|
+
# generated OpenAPI document.
|
|
5
|
+
#
|
|
6
|
+
# Not from the controllers: most of them declare their writable attributes
|
|
7
|
+
# inside `permitted_params`, where the list is a `params.permit` call that
|
|
8
|
+
# cannot be read without a request. `admin.yaml` already carries the same
|
|
9
|
+
# list — generated from the integration specs, reviewed as a diff on every
|
|
10
|
+
# API change — with the types, descriptions and examples a model needs to
|
|
11
|
+
# fill a form in correctly. Reading it means the generic write tools offer
|
|
12
|
+
# exactly the documented endpoint and nothing else.
|
|
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.
|
|
16
|
+
module AgentWriteSchemas
|
|
17
|
+
# A collection endpoint under the admin namespace: `/api/v3/admin/markets`
|
|
18
|
+
# but not `/api/v3/admin/markets/{id}` or a nested child.
|
|
19
|
+
COLLECTION_PATH = %r{\A/api/v3/admin/(?<key>[a-z0-9_]+)\z}
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# The writable properties for one resource.
|
|
23
|
+
#
|
|
24
|
+
# @param key [String] the resource key, e.g. "markets"
|
|
25
|
+
# @return [Hash] JSON Schema properties, empty when the resource has no
|
|
26
|
+
# documented write
|
|
27
|
+
def for(key)
|
|
28
|
+
schemas[key.to_s] || {}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Array<String>] the attribute names a write may set
|
|
32
|
+
def attribute_names(key)
|
|
33
|
+
self.for(key).keys
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Where the generated document lives, relative to this engine. Resolved
|
|
37
|
+
# lazily: the engine class does not exist yet while this file loads.
|
|
38
|
+
#
|
|
39
|
+
# @return [Pathname]
|
|
40
|
+
def spec_path
|
|
41
|
+
Spree::Api::Engine.root.join('..', '..', 'docs', 'api-reference', 'admin.yaml')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The parsed document, for a reader that needs more of it than the
|
|
45
|
+
# write bodies — the workflow endpoint map wants the member actions'
|
|
46
|
+
# bodies too, and parsing a megabyte of YAML twice is wasteful.
|
|
47
|
+
#
|
|
48
|
+
# @return [Hash]
|
|
49
|
+
def document
|
|
50
|
+
@document ||= begin
|
|
51
|
+
path = spec_path
|
|
52
|
+
File.exist?(path) ? (YAML.unsafe_load_file(path) || {}) : {}
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
Rails.logger.warn("[Spree] could not read the admin API document: #{e.class}: #{e.message}")
|
|
55
|
+
{}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def schemas
|
|
62
|
+
@schemas ||= load_schemas
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A missing or unparseable document is not a boot failure: the generic
|
|
66
|
+
# write tools simply offer nothing, which is the safe direction. An
|
|
67
|
+
# installation running from the packaged gems has no docs/ directory at
|
|
68
|
+
# all.
|
|
69
|
+
def load_schemas
|
|
70
|
+
path = spec_path
|
|
71
|
+
return {} unless File.exist?(path)
|
|
72
|
+
|
|
73
|
+
document = YAML.unsafe_load_file(path)
|
|
74
|
+
extract(document)
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
Rails.logger.warn("[Spree] could not read admin API write schemas: #{e.class}: #{e.message}")
|
|
77
|
+
{}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def extract(document)
|
|
81
|
+
Array(document['paths']).to_h.each_with_object({}) do |(path, operations), result|
|
|
82
|
+
match = COLLECTION_PATH.match(path)
|
|
83
|
+
next if match.nil?
|
|
84
|
+
|
|
85
|
+
properties = write_properties(operations)
|
|
86
|
+
result[match[:key]] = properties if properties.present?
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The create body where there is one, because it documents every
|
|
91
|
+
# settable attribute; an update body usually repeats a subset.
|
|
92
|
+
def write_properties(operations)
|
|
93
|
+
body = operations.dig('post', 'requestBody', 'content', 'application/json', 'schema')
|
|
94
|
+
body ||= operations.dig('patch', 'requestBody', 'content', 'application/json', 'schema')
|
|
95
|
+
|
|
96
|
+
resolve_schema(body)&.dig('properties').presence
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
public
|
|
100
|
+
|
|
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. Followed a few hops, which is as deep as the
|
|
104
|
+
# generated document nests them.
|
|
105
|
+
#
|
|
106
|
+
# @param schema [Hash, nil] a schema that may be a reference
|
|
107
|
+
# @return [Hash, nil] the schema it names
|
|
108
|
+
def resolve_schema(schema, depth = 0)
|
|
109
|
+
return unless schema.is_a?(Hash)
|
|
110
|
+
return schema unless schema.key?('$ref')
|
|
111
|
+
return if depth > 3
|
|
112
|
+
|
|
113
|
+
name = schema['$ref'].to_s.split('/').last
|
|
114
|
+
resolve_schema(document.dig('components', 'schemas', name), depth + 1)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -32,13 +32,14 @@ module Spree
|
|
|
32
32
|
exchange exchange_line_item claim claim_line_item package_type policy tax_category tax_rate
|
|
33
33
|
tax_identifier company company_membership company_invitation catalog catalog_price catalog_price_tier
|
|
34
34
|
catalog_product price_list_product catalog_assignment catalog_quantity_rule catalog_product_term
|
|
35
|
-
catalog_order_minimum tax_exemption_certificate admin_user actor seller_team_member product_submission
|
|
35
|
+
catalog_order_minimum tax_exemption_certificate tax_exemption_reason admin_user actor seller_team_member product_submission
|
|
36
36
|
seller seller_requirement seller_requirement_submission seller_requirement_status commission_rate
|
|
37
|
-
commission_rule commission_line seller_transfer seller_payout seller_balance payment_split address
|
|
37
|
+
commission_rule commission_line seller_transfer seller_payout seller_balance payment_split payment_capture_event address
|
|
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
|
|
41
|
+
store setup_task update_status api_key oauth_application oauth_access_token oauth_consent_request oauth_redirect
|
|
42
|
+
oauth_protected_resource allowed_origin webhook_endpoint webhook_event webhook_delivery invitation
|
|
42
43
|
invitation_acceptance_link role permission export saved_report import import_row import_mapping promotion
|
|
43
44
|
promotion_action promotion_rule coupon_code price_adjustment_tier price_list price_rule
|
|
44
45
|
resource_translations email_template email_template_draft email_template_revision email_template_preview
|
data/lib/spree/api/engine.rb
CHANGED
|
@@ -2,6 +2,11 @@ require 'rails/engine'
|
|
|
2
2
|
|
|
3
3
|
require_relative 'dependencies'
|
|
4
4
|
require_relative 'configuration'
|
|
5
|
+
require_relative 'agent_write_schemas'
|
|
6
|
+
require_relative 'agent_resource_map'
|
|
7
|
+
require_relative 'agent_membership_map'
|
|
8
|
+
require_relative 'agent_workflow_endpoints'
|
|
9
|
+
require_relative 'oauth'
|
|
5
10
|
|
|
6
11
|
module Spree
|
|
7
12
|
module Api
|
|
@@ -14,11 +19,46 @@ module Spree
|
|
|
14
19
|
Spree::Api::Dependencies = Spree::Api::ApiDependencies.new
|
|
15
20
|
end
|
|
16
21
|
|
|
22
|
+
# Applied from the engine so an installation gets a correct, locked-down
|
|
23
|
+
# authorization server by adding the gem.
|
|
24
|
+
#
|
|
25
|
+
# Before host initializers, not after: `Doorkeeper.configure` replaces
|
|
26
|
+
# the whole configuration, so running later would silently discard a
|
|
27
|
+
# host's own `config/initializers/doorkeeper.rb`. This way the host's
|
|
28
|
+
# file wins, which is what "configure it yourself" has to mean.
|
|
29
|
+
initializer 'spree.api.oauth', before: :load_config_initializers do |_app|
|
|
30
|
+
Spree::Api::Oauth.configure!
|
|
31
|
+
|
|
32
|
+
# The Admin API as a whole, for an app that works across it rather
|
|
33
|
+
# than through one endpoint. A token naming this audience is accepted
|
|
34
|
+
# on every admin controller; one naming a narrower resource — the MCP
|
|
35
|
+
# endpoint, say — is not, so a token taken from an MCP client cannot
|
|
36
|
+
# be replayed against the rest of the API.
|
|
37
|
+
Spree::Api::Oauth.register_resource(:admin, '/api/v3/admin')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Extensions register their permission scopes from config initializers,
|
|
41
|
+
# which run after the configuration above — so the grantable list is
|
|
42
|
+
# re-read once they have.
|
|
43
|
+
config.after_initialize do
|
|
44
|
+
Spree::Api::Oauth.refresh_scopes!
|
|
45
|
+
end
|
|
46
|
+
|
|
17
47
|
initializer 'spree.api.request_size_limit' do |app|
|
|
18
48
|
require_relative 'middleware/request_size_limit'
|
|
19
49
|
app.middleware.insert_before Rack::Runtime, Spree::Api::Middleware::RequestSizeLimit
|
|
20
50
|
end
|
|
21
51
|
|
|
52
|
+
# The resources agents may read and write are derived from the admin
|
|
53
|
+
# controllers themselves (see Spree::Api::AgentResourceMap). Marked
|
|
54
|
+
# stale on every reload rather than rebuilt: deriving it eager-loads the
|
|
55
|
+
# application, and doing that on each file save would turn Spree's
|
|
56
|
+
# development reload from lazy into fully eager for everyone, whether or
|
|
57
|
+
# not they run an agent client. The first caller pays instead.
|
|
58
|
+
config.to_prepare do
|
|
59
|
+
Spree::Api::AgentResourceMap.stale!
|
|
60
|
+
end
|
|
61
|
+
|
|
22
62
|
# Add API event subscribers
|
|
23
63
|
config.after_initialize do
|
|
24
64
|
Spree.subscribers << Spree::WebhookEventSubscriber
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
# Read-only fields an extension adds to a core serializer's responses —
|
|
4
|
+
# the read side of +additional_permitted_attributes+.
|
|
5
|
+
#
|
|
6
|
+
# A field is registered against a serializer's dependency name, not its
|
|
7
|
+
# class, so a host app that replaces the serializer through +Spree.api+
|
|
8
|
+
# still renders it. An attribute writes its value as it is; an association
|
|
9
|
+
# renders a related record, or a list of them, with the extension's own
|
|
10
|
+
# serializer. Fields are left out of the generated SDK types: the
|
|
11
|
+
# extension that adds one declares its type.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# Spree.api.extension_fields.attribute(:admin_product_serializer, method: :warranty_text)
|
|
15
|
+
# Spree.api.extension_fields.association(
|
|
16
|
+
# :admin_product_serializer, one: :warranty,
|
|
17
|
+
# serializer: 'MyExtension::WarrantySerializer', expand: true
|
|
18
|
+
# )
|
|
19
|
+
class ExtensionFields
|
|
20
|
+
# What attributes and associations share: a name, whether it waits for
|
|
21
|
+
# an expand, and where its value comes from.
|
|
22
|
+
class Field
|
|
23
|
+
attr_reader :name
|
|
24
|
+
|
|
25
|
+
def initialize(name:, expand:, value:)
|
|
26
|
+
@name = name
|
|
27
|
+
@expand = expand
|
|
28
|
+
@value = value
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Boolean] whether the field is shown only when the request expands it
|
|
32
|
+
def expand?
|
|
33
|
+
@expand
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Run inside the core serializer, so it sees the request's expands.
|
|
37
|
+
# @return [Proc]
|
|
38
|
+
def reader
|
|
39
|
+
field = self
|
|
40
|
+
@reader ||= proc do |object|
|
|
41
|
+
next Alba::REMOVE_KEY if field.expand? && !expand?(field.name)
|
|
42
|
+
|
|
43
|
+
field.render(field.value_for(object), params: nested_params(field.name))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# An extension cannot add a method to a core model, so +value+ can
|
|
48
|
+
# supply the value from the record instead.
|
|
49
|
+
#
|
|
50
|
+
# @param record [Object] the record the core serializer is writing
|
|
51
|
+
# @return [Object]
|
|
52
|
+
def value_for(record)
|
|
53
|
+
@value ? @value.call(record) : record.public_send(name)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A value written as it is.
|
|
58
|
+
class Attribute < Field
|
|
59
|
+
# @return [Object]
|
|
60
|
+
def render(value, **)
|
|
61
|
+
value
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A related record, or a list of them, written by the extension's
|
|
66
|
+
# serializer, which also receives the request's nested expands.
|
|
67
|
+
class Association < Field
|
|
68
|
+
def initialize(serializer:, **)
|
|
69
|
+
super(**)
|
|
70
|
+
@serializer = serializer
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Hash, Array<Hash>, nil]
|
|
74
|
+
def render(value, params:)
|
|
75
|
+
return if value.nil?
|
|
76
|
+
|
|
77
|
+
serializer_class.new(value, params: params).serializable_hash
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def serializer_class
|
|
83
|
+
@serializer.is_a?(String) ? @serializer.constantize : @serializer
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class DuplicateField < ArgumentError; end
|
|
88
|
+
|
|
89
|
+
def initialize
|
|
90
|
+
@fields = Hash.new { |hash, key| hash[key] = {} }
|
|
91
|
+
@class_readers = {}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Either names the record's method that gives the value, or names the
|
|
95
|
+
# field and supplies its value:
|
|
96
|
+
#
|
|
97
|
+
# attribute(:admin_product_serializer, method: :warranty_text)
|
|
98
|
+
# attribute(:admin_product_serializer, :warranty_months, value: ->(product) { ... })
|
|
99
|
+
#
|
|
100
|
+
# @param dependency [Symbol] the serializer's dependency name, e.g. +:admin_product_serializer+
|
|
101
|
+
# @param name [Symbol, String, nil] the response key of a field whose value is given by +value+
|
|
102
|
+
# @param method [Symbol, String, nil] the response key, and the record's method that gives the value
|
|
103
|
+
# @param expand [Boolean] true to show the field only when the request expands it
|
|
104
|
+
# @param value [#call, nil] gives the value from the record; required with +name+, refused with +method+
|
|
105
|
+
# @raise [ArgumentError] when the dependency is not a serializer, or the name and value do not match up
|
|
106
|
+
# @raise [DuplicateField] when the serializer already has a field of that name
|
|
107
|
+
# @return [void]
|
|
108
|
+
def attribute(dependency, name = nil, method: nil, expand: false, value: nil)
|
|
109
|
+
raise ArgumentError, 'Pass either a name or method:' unless name.present? ^ method.present?
|
|
110
|
+
raise ArgumentError, 'A named attribute needs value:' if name.present? && value.nil?
|
|
111
|
+
raise ArgumentError, 'An attribute reading method: takes no value:' if method.present? && value
|
|
112
|
+
|
|
113
|
+
add(dependency, Attribute.new(name: (name || method).to_sym, expand: expand, value: value))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @param dependency [Symbol] the serializer's dependency name, e.g. +:admin_product_serializer+
|
|
117
|
+
# @param serializer [Class, String] writes each related record
|
|
118
|
+
# @param one [Symbol, String, nil] the response key of a single related record, read from the record's method of that name
|
|
119
|
+
# @param many [Symbol, String, nil] the same, for a list of related records
|
|
120
|
+
# @param expand [Boolean] true to show the field only when the request expands it
|
|
121
|
+
# @param value [#call, nil] gives the related record, or records, instead of calling the method
|
|
122
|
+
# @raise [ArgumentError] when the dependency is not a serializer, or not exactly one of +one+ and +many+ is given
|
|
123
|
+
# @raise [DuplicateField] when the serializer already has a field of that name
|
|
124
|
+
# @return [void]
|
|
125
|
+
def association(dependency, serializer:, one: nil, many: nil, expand: false, value: nil)
|
|
126
|
+
raise ArgumentError, 'Pass either one: or many:' unless one.present? ^ many.present?
|
|
127
|
+
|
|
128
|
+
add(dependency, Association.new(name: (one || many).to_sym, serializer: serializer, expand: expand, value: value))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [void]
|
|
132
|
+
def unregister(dependency, name)
|
|
133
|
+
@fields[dependency.to_sym].delete(name.to_sym)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The fields of every dependency that currently resolves to this class.
|
|
137
|
+
#
|
|
138
|
+
# @param serializer_class [Class]
|
|
139
|
+
# @return [Array<Field>]
|
|
140
|
+
def for(serializer_class)
|
|
141
|
+
@fields.each_with_object([]) do |(dependency, fields), result|
|
|
142
|
+
# The dependency's cached reader, skipping Spree.api's method_missing on every render.
|
|
143
|
+
next if fields.empty? || Spree::Api::Dependencies.public_send(@class_readers[dependency]) != serializer_class
|
|
144
|
+
|
|
145
|
+
result.concat(fields.values)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def add(dependency, field)
|
|
152
|
+
dependency = dependency.to_sym
|
|
153
|
+
# Current names only: a pre-rename key would be resolved through its
|
|
154
|
+
# deprecation warning on every render.
|
|
155
|
+
unless dependency.to_s.end_with?('_serializer') && Spree::Api::Dependencies.class::INJECTION_POINTS.include?(dependency)
|
|
156
|
+
raise ArgumentError, "#{dependency} is not an API serializer"
|
|
157
|
+
end
|
|
158
|
+
if @fields[dependency].key?(field.name) || Spree.api.public_send(dependency)._attributes.key?(field.name)
|
|
159
|
+
raise DuplicateField, "#{dependency} already has a field named #{field.name}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
@fields[dependency][field.name] = field
|
|
163
|
+
@class_readers[dependency] ||= :"#{dependency}_class"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|