spree_api 6.0.0.rc3 → 6.0.0.rc5

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 (156) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/spree/api/v3/admin/email_template_lookup.rb +6 -1
  3. data/app/controllers/concerns/spree/api/v3/admin/reporting_authorization.rb +17 -8
  4. data/app/controllers/concerns/spree/api/v3/admin/store_context.rb +7 -0
  5. data/app/controllers/concerns/spree/api/v3/admin_authentication.rb +61 -0
  6. data/app/controllers/concerns/spree/api/v3/allowed_redirect_url.rb +27 -0
  7. data/app/controllers/concerns/spree/api/v3/cart_resolvable.rb +8 -2
  8. data/app/controllers/concerns/spree/api/v3/error_handler.rb +49 -0
  9. data/app/controllers/concerns/spree/api/v3/params_normalizer.rb +76 -7
  10. data/app/controllers/concerns/spree/api/v3/scoped_authorization.rb +66 -5
  11. data/app/controllers/spree/api/v3/admin/companies/addresses_controller.rb +7 -1
  12. data/app/controllers/spree/api/v3/admin/companies/tax_exemption_certificates_controller.rb +11 -5
  13. data/app/controllers/spree/api/v3/admin/concerns/external_references.rb +3 -0
  14. data/app/controllers/spree/api/v3/admin/custom_fields_controller.rb +30 -8
  15. data/app/controllers/spree/api/v3/admin/customers/addresses_controller.rb +4 -0
  16. data/app/controllers/spree/api/v3/admin/customers_controller.rb +1 -6
  17. data/app/controllers/spree/api/v3/admin/exports_controller.rb +15 -3
  18. data/app/controllers/spree/api/v3/admin/gift_cards_controller.rb +1 -8
  19. data/app/controllers/spree/api/v3/admin/imports_controller.rb +12 -3
  20. data/app/controllers/spree/api/v3/admin/markets_controller.rb +3 -1
  21. data/app/controllers/spree/api/v3/admin/me_controller.rb +21 -1
  22. data/app/controllers/spree/api/v3/admin/oauth/application_tokens_controller.rb +56 -0
  23. data/app/controllers/spree/api/v3/admin/oauth/applications_controller.rb +55 -0
  24. data/app/controllers/spree/api/v3/admin/oauth/authorizations_controller.rb +294 -0
  25. data/app/controllers/spree/api/v3/admin/order_groups_controller.rb +1 -1
  26. data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +8 -0
  27. data/app/controllers/spree/api/v3/admin/orders/items_controller.rb +1 -0
  28. data/app/controllers/spree/api/v3/admin/orders/returns_controller.rb +7 -0
  29. data/app/controllers/spree/api/v3/admin/orders_controller.rb +20 -7
  30. data/app/controllers/spree/api/v3/admin/package_types_controller.rb +2 -3
  31. data/app/controllers/spree/api/v3/admin/products/variants_controller.rb +3 -4
  32. data/app/controllers/spree/api/v3/admin/products_controller.rb +5 -3
  33. data/app/controllers/spree/api/v3/admin/purchase_orders_controller.rb +1 -0
  34. data/app/controllers/spree/api/v3/admin/reporting_controller.rb +20 -14
  35. data/app/controllers/spree/api/v3/admin/stock_levels_controller.rb +1 -1
  36. data/app/controllers/spree/api/v3/admin/store_controller.rb +1 -1
  37. data/app/controllers/spree/api/v3/admin/tags_controller.rb +8 -5
  38. data/app/controllers/spree/api/v3/admin/tax_exemption_reasons_controller.rb +58 -0
  39. data/app/controllers/spree/api/v3/admin/tax_rates_controller.rb +0 -1
  40. data/app/controllers/spree/api/v3/admin/translations/batches_controller.rb +17 -11
  41. data/app/controllers/spree/api/v3/admin/variants_controller.rb +96 -1
  42. data/app/controllers/spree/api/v3/oauth/authorizations_controller.rb +72 -0
  43. data/app/controllers/spree/api/v3/oauth/metadata_controller.rb +57 -0
  44. data/app/controllers/spree/api/v3/oauth/server_metadata_controller.rb +57 -0
  45. data/app/controllers/spree/api/v3/resource_controller.rb +36 -5
  46. data/app/controllers/spree/api/v3/seller/package_types_controller.rb +2 -2
  47. data/app/controllers/spree/api/v3/seller/products_controller.rb +7 -61
  48. data/app/controllers/spree/api/v3/seller/resource_controller.rb +2 -6
  49. data/app/controllers/spree/api/v3/store/carts/addresses_controller.rb +53 -0
  50. data/app/controllers/spree/api/v3/store/carts_controller.rb +7 -8
  51. data/app/controllers/spree/api/v3/store/companies/addresses_controller.rb +4 -0
  52. data/app/controllers/spree/api/v3/store/customer/addresses_controller.rb +4 -0
  53. data/app/controllers/spree/api/v3/store/customer/data_requests_controller.rb +19 -1
  54. data/app/controllers/spree/api/v3/store/customer/password_resets_controller.rb +3 -14
  55. data/app/controllers/spree/api/v3/store/data_request_confirmations_controller.rb +23 -0
  56. data/app/controllers/spree/api/v3/store/data_requests_controller.rb +56 -0
  57. data/app/controllers/spree/api/v3/store/newsletter_subscribers_controller.rb +2 -11
  58. data/app/request_schemas/spree/api/v3/admin/email_template_preview_request_schema.rb +4 -0
  59. data/app/request_schemas/spree/api/v3/admin/variant_request_schema.rb +21 -0
  60. data/app/request_schemas/spree/api/v3/seller/price_request_schema.rb +20 -0
  61. data/app/request_schemas/spree/api/v3/seller/variant_request_schema.rb +19 -0
  62. data/app/request_schemas/spree/api/v3/variant_request_schema.rb +8 -13
  63. data/app/serializers/spree/api/v3/address_owner_serializer.rb +27 -0
  64. data/app/serializers/spree/api/v3/address_serializer.rb +5 -1
  65. data/app/serializers/spree/api/v3/admin/address_owner_serializer.rb +10 -0
  66. data/app/serializers/spree/api/v3/admin/address_serializer.rb +1 -10
  67. data/app/serializers/spree/api/v3/admin/api_key_serializer.rb +1 -1
  68. data/app/serializers/spree/api/v3/admin/catalog_assignment_serializer.rb +1 -1
  69. data/app/serializers/spree/api/v3/admin/catalog_price_serializer.rb +1 -1
  70. data/app/serializers/spree/api/v3/admin/collection_rule_serializer.rb +1 -1
  71. data/app/serializers/spree/api/v3/admin/collection_serializer.rb +1 -1
  72. data/app/serializers/spree/api/v3/admin/commission_line_serializer.rb +2 -2
  73. data/app/serializers/spree/api/v3/admin/commission_rate_serializer.rb +1 -1
  74. data/app/serializers/spree/api/v3/admin/consent_record_serializer.rb +1 -1
  75. data/app/serializers/spree/api/v3/admin/email_template_serializer.rb +1 -1
  76. data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +2 -0
  77. data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +2 -0
  78. data/app/serializers/spree/api/v3/admin/oauth_access_token_serializer.rb +62 -0
  79. data/app/serializers/spree/api/v3/admin/oauth_application_serializer.rb +56 -0
  80. data/app/serializers/spree/api/v3/admin/oauth_consent_request_serializer.rb +25 -0
  81. data/app/serializers/spree/api/v3/admin/oauth_protected_resource_serializer.rb +27 -0
  82. data/app/serializers/spree/api/v3/admin/oauth_redirect_serializer.rb +17 -0
  83. data/app/serializers/spree/api/v3/admin/order_serializer.rb +1 -1
  84. data/app/serializers/spree/api/v3/admin/package_type_serializer.rb +3 -3
  85. data/app/serializers/spree/api/v3/admin/payment_capture_event_serializer.rb +16 -0
  86. data/app/serializers/spree/api/v3/admin/payment_serializer.rb +12 -1
  87. data/app/serializers/spree/api/v3/admin/permission_serializer.rb +1 -1
  88. data/app/serializers/spree/api/v3/admin/price_list_serializer.rb +1 -1
  89. data/app/serializers/spree/api/v3/admin/purchase_order_serializer.rb +1 -1
  90. data/app/serializers/spree/api/v3/admin/return_serializer.rb +2 -0
  91. data/app/serializers/spree/api/v3/admin/seller_requirement_status_serializer.rb +1 -1
  92. data/app/serializers/spree/api/v3/admin/seller_serializer.rb +2 -2
  93. data/app/serializers/spree/api/v3/admin/seller_transfer_serializer.rb +1 -1
  94. data/app/serializers/spree/api/v3/admin/shipping_label_serializer.rb +3 -3
  95. data/app/serializers/spree/api/v3/admin/stock_location_serializer.rb +1 -1
  96. data/app/serializers/spree/api/v3/admin/stock_receipt_item_serializer.rb +2 -2
  97. data/app/serializers/spree/api/v3/admin/stock_receipt_serializer.rb +1 -1
  98. data/app/serializers/spree/api/v3/admin/stock_transfer_serializer.rb +1 -1
  99. data/app/serializers/spree/api/v3/admin/store_serializer.rb +1 -0
  100. data/app/serializers/spree/api/v3/admin/tax_exemption_certificate_serializer.rb +7 -3
  101. data/app/serializers/spree/api/v3/admin/tax_exemption_reason_serializer.rb +20 -0
  102. data/app/serializers/spree/api/v3/admin/tax_identifier_serializer.rb +2 -2
  103. data/app/serializers/spree/api/v3/admin/tax_line_serializer.rb +1 -1
  104. data/app/serializers/spree/api/v3/admin/tax_rate_serializer.rb +1 -2
  105. data/app/serializers/spree/api/v3/admin/variant_serializer.rb +1 -1
  106. data/app/serializers/spree/api/v3/admin/webhook_delivery_serializer.rb +4 -1
  107. data/app/serializers/spree/api/v3/base_serializer.rb +41 -5
  108. data/app/serializers/spree/api/v3/claim_serializer.rb +1 -1
  109. data/app/serializers/spree/api/v3/collection_serializer.rb +1 -1
  110. data/app/serializers/spree/api/v3/company_invitation_serializer.rb +1 -1
  111. data/app/serializers/spree/api/v3/company_serializer.rb +1 -1
  112. data/app/serializers/spree/api/v3/data_request_confirmation_requested_event_serializer.rb +28 -0
  113. data/app/serializers/spree/api/v3/data_request_event_serializer.rb +1 -1
  114. data/app/serializers/spree/api/v3/data_request_serializer.rb +8 -4
  115. data/app/serializers/spree/api/v3/delivery_serializer.rb +1 -1
  116. data/app/serializers/spree/api/v3/delivery_zone_member_serializer.rb +1 -1
  117. data/app/serializers/spree/api/v3/discount_serializer.rb +1 -1
  118. data/app/serializers/spree/api/v3/file_upload_serializer.rb +1 -1
  119. data/app/serializers/spree/api/v3/fulfillment_serializer.rb +1 -1
  120. data/app/serializers/spree/api/v3/media_event_serializer.rb +1 -1
  121. data/app/serializers/spree/api/v3/media_serializer.rb +1 -1
  122. data/app/serializers/spree/api/v3/option_type_serializer.rb +1 -1
  123. data/app/serializers/spree/api/v3/order_group_serializer.rb +2 -2
  124. data/app/serializers/spree/api/v3/order_serializer.rb +4 -2
  125. data/app/serializers/spree/api/v3/payment_serializer.rb +1 -1
  126. data/app/serializers/spree/api/v3/product_filter_availability_option_serializer.rb +1 -1
  127. data/app/serializers/spree/api/v3/product_filter_option_serializer.rb +1 -1
  128. data/app/serializers/spree/api/v3/seller/address_serializer.rb +3 -2
  129. data/app/serializers/spree/api/v3/seller/order_serializer.rb +3 -3
  130. data/app/serializers/spree/api/v3/seller/package_type_serializer.rb +3 -3
  131. data/app/serializers/spree/api/v3/seller/profile_serializer.rb +2 -2
  132. data/app/serializers/spree/api/v3/seller/requirement_status_serializer.rb +1 -1
  133. data/app/serializers/spree/api/v3/seller/tax_identifier_serializer.rb +1 -1
  134. data/app/serializers/spree/api/v3/seller/variant_serializer.rb +1 -1
  135. data/app/serializers/spree/api/v3/shipping_label_event_serializer.rb +1 -1
  136. data/app/serializers/spree/api/v3/stock_movement_serializer.rb +1 -1
  137. data/app/serializers/spree/api/v3/stock_receipt_event_serializer.rb +1 -1
  138. data/app/serializers/spree/api/v3/variant_serializer.rb +2 -2
  139. data/app/services/spree/api/v3/filter_table.rb +22 -3
  140. data/app/services/spree/api/v3/request_schema.rb +64 -17
  141. data/config/locales/en.yml +1 -0
  142. data/config/routes.rb +80 -12
  143. data/lib/spree/api/agent_membership_map.rb +73 -0
  144. data/lib/spree/api/agent_resource_map.rb +350 -0
  145. data/lib/spree/api/agent_workflow_endpoints.rb +113 -0
  146. data/lib/spree/api/agent_write_schemas.rb +131 -0
  147. data/lib/spree/api/dependencies.rb +5 -4
  148. data/lib/spree/api/engine.rb +40 -0
  149. data/lib/spree/api/enum_types.rb +118 -0
  150. data/lib/spree/api/extension_fields.rb +167 -0
  151. data/lib/spree/api/oauth.rb +243 -0
  152. data/lib/spree/api/openapi/filter_documentation.rb +8 -1
  153. data/lib/spree/api/typelizer_generation.rb +5 -0
  154. data/lib/spree/api.rb +8 -0
  155. data/lib/typelizer/open_enums.rb +48 -10
  156. metadata +37 -6
@@ -0,0 +1,118 @@
1
+ module Spree
2
+ module Api
3
+ # Renders every value list the serializers name (`enum_type_name:`, see
4
+ # {Typelizer::OpenEnums}) into `@spree/core`, once, as both the values and
5
+ # the type — open unless the name is one of `CLOSED_LIST_NAMES`. Each SDK's `Enums.ts` re-exports the type
6
+ # from there instead of carrying its own copy, and the dashboards read the
7
+ # values instead of repeating them by hand.
8
+ #
9
+ # Run by `rake typelizer:generate`; `enum_types_spec.rb` fails when the
10
+ # committed file no longer matches the serializers.
11
+ class EnumTypes
12
+ class ConflictError < StandardError; end
13
+
14
+ HEADER = "// This file is auto-generated from the API serializers by `rake typelizer:generate`. Do not edit directly.\n".freeze
15
+ PATH = 'packages/core/src/enums.generated.ts'.freeze
16
+ LINE_WIDTH = 100
17
+
18
+ # @param properties [Array<Typelizer::Property>] every named enum property, across all serializers
19
+ def initialize(properties = self.class.serializer_properties)
20
+ @properties = properties
21
+ end
22
+
23
+ # Walks every property rather than Typelizer's own `enum_types`, which
24
+ # keeps one property per name within a serializer and so would hide two
25
+ # attributes of one serializer that disagree.
26
+ #
27
+ # @return [Array<Typelizer::Property>] the named enum properties of every serializer each Typelizer writer emits
28
+ def self.serializer_properties
29
+ Typelizer.configuration.writers.keys
30
+ .flat_map { |writer_name| Typelizer.interfaces(writer_name: writer_name) }
31
+ .flat_map { |interface| named_properties(interface.properties + interface.trait_interfaces.flat_map(&:properties)) }
32
+ end
33
+
34
+ # @param properties [Array<Typelizer::Property>]
35
+ # @return [Array<Typelizer::Property>] those carrying a named list, nested and inline ones included
36
+ def self.named_properties(properties)
37
+ properties.flat_map do |property|
38
+ nested = property.nested_properties.to_a
39
+ nested += property.type.properties if property.type.is_a?(Typelizer::Interface) && property.type.inline?
40
+
41
+ [property, *named_properties(nested)]
42
+ end.select { |property| property.enum && property.enum_type_name }
43
+ end
44
+
45
+ # @return [Hash{String => Array<String>}] built-in values keyed by type name
46
+ # @raise [ConflictError] when two serializers give one type name different values
47
+ def lists
48
+ @lists ||= @properties
49
+ .select(&:enum_type_name)
50
+ .reject { |property| Typelizer::OpenEnums::CLOSED_TYPE_NAMES.include?(property.enum_type_name) }
51
+ .group_by(&:enum_type_name)
52
+ .sort
53
+ .to_h { |name, properties| [name, values_of(name, properties)] }
54
+ end
55
+
56
+ # @return [Array<String>] closed list names no serializer declares any more
57
+ def stale_closed_names
58
+ Typelizer::OpenEnums::CLOSED_LIST_NAMES - lists.keys
59
+ end
60
+
61
+ # @return [String] the file's contents
62
+ def render
63
+ HEADER + lists.map { |name, values| definition(name, values) }.join + built_in_status
64
+ end
65
+
66
+ # @param root [Pathname] the monorepo root
67
+ # @return [void]
68
+ # @raise [ConflictError] when `CLOSED_LIST_NAMES` names a list no serializer declares
69
+ def write!(root)
70
+ raise ConflictError, "No serializer names #{stale_closed_names.join(', ')}; remove it from CLOSED_LIST_NAMES." if stale_closed_names.any?
71
+
72
+ File.write(root.join(PATH), render)
73
+ end
74
+
75
+ # @param name [String] a type name, e.g. `ProductStatus`
76
+ # @return [String] the constant holding its values, e.g. `PRODUCT_STATUSES`
77
+ def self.constant_name(name)
78
+ name.underscore.pluralize.upcase
79
+ end
80
+
81
+ private
82
+
83
+ def values_of(name, properties)
84
+ variants = properties.map { |property| property.enum.map(&:to_s) }.uniq
85
+ return variants.first if variants.one?
86
+
87
+ raise ConflictError, "#{name} is declared with different values: " \
88
+ "#{variants.map { |values| values.join(', ') }.join(' / ')}. " \
89
+ 'Give every serializer the same list, or give the lists different names.'
90
+ end
91
+
92
+ # Every built-in value of every status list, so a surface that styles
93
+ # statuses can be checked for one the server added.
94
+ def built_in_status
95
+ members = lists.keys.grep(/Status\z/).map { |name| " | (typeof #{self.class.constant_name(name)})[number]\n" }
96
+ return '' if members.empty?
97
+
98
+ "\n/** Every built-in value of every status list above. */\nexport type BuiltInStatus =\n#{members.join}"
99
+ end
100
+
101
+ def definition(name, values)
102
+ constant = self.class.constant_name(name)
103
+ quoted = values.map { |value| "'#{value}'" }
104
+ line = "export const #{constant} = [#{quoted.join(', ')}] as const"
105
+ line = "export const #{constant} = [\n#{quoted.map { |value| " #{value},\n" }.join}] as const" if line.length > LINE_WIDTH
106
+
107
+ if Typelizer::OpenEnums.closed?(name)
108
+ "\n/** Every value of `#{name}`. */\n#{line}\nexport type #{name} = (typeof #{constant})[number]\n"
109
+ else
110
+ type = "export type #{name} = (typeof #{constant})[number] | (string & {})"
111
+ type = "export type #{name} =\n | (typeof #{constant})[number]\n | (string & {})" if type.length > LINE_WIDTH
112
+
113
+ "\n/** The built-in values of `#{name}`. Extensions may add more. */\n#{line}\n#{type}\n"
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -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
@@ -0,0 +1,243 @@
1
+ module Spree
2
+ module Api
3
+ # OAuth 2.1 authorization server for the Spree API.
4
+ #
5
+ # Spree is the authorization server here, not a client of one: a
6
+ # self-hosted store has no external identity provider to delegate to, and
7
+ # requiring one would exclude the installations this exists for. This is
8
+ # the opposite direction from SSO login, which makes Spree an OIDC client
9
+ # so staff can sign in through a provider; the two share a spec family and
10
+ # almost no code.
11
+ #
12
+ # The first consumer is the Admin MCP server, whose consumer clients
13
+ # (hosted connectors) accept no other credential. A marketplace app or a
14
+ # merchant-approved integration is the same flow with a different client,
15
+ # which is why this lives in `spree_api` rather than in any one gem that
16
+ # happens to need it.
17
+ module Oauth
18
+ # Protected resources, each registered by the surface that owns it as a
19
+ # path relative to this application's own origin.
20
+ #
21
+ # Spree::Api::Oauth.register_resource(:admin, '/api/v3/admin')
22
+ #
23
+ # A path rather than a full URL because the identifier must equal the
24
+ # URL the client was actually pointed at: the origin comes from the
25
+ # request where there is one, and from the application's configured
26
+ # URL options otherwise. A store's own `url` column is deliberately not
27
+ # consulted — it describes the storefront, which is frequently neither
28
+ # the host nor the scheme the Admin API answers on.
29
+ # Whether a redirect URI points at the machine the client runs on.
30
+ #
31
+ # Decided by parsing the host as an address, the same way Doorkeeper
32
+ # does when it ignores the port of a loopback redirect — so `localhost`
33
+ # is deliberately not loopback here either, and a registration must use
34
+ # the IP literal for an ephemeral port to match.
35
+ #
36
+ # @param uri [URI, String]
37
+ # @return [Boolean]
38
+ def self.loopback_uri?(uri)
39
+ host = uri.respond_to?(:host) ? uri.host : URI.parse(uri.to_s).host
40
+ return false if host.blank?
41
+
42
+ IPAddr.new(host).loopback?
43
+ rescue StandardError
44
+ false
45
+ end
46
+
47
+ mattr_accessor :resources, default: {}
48
+
49
+ class << self
50
+ # @param key [Symbol]
51
+ # @param path [String] absolute path this resource answers on
52
+ def register_resource(key, path)
53
+ resources[key.to_sym] = path
54
+ end
55
+
56
+ # @param origin [String, nil] scheme and host the client used
57
+ # @return [Array<String>] every resource identifier this installation
58
+ # serves
59
+ def resource_identifiers(origin = nil)
60
+ base = origin.presence || application_origin
61
+ resources.values.map { |path| "#{base}#{path}" }
62
+ end
63
+
64
+ # @param key [Symbol]
65
+ # @param path [String] absolute path this resource answers on
66
+ def register_resource(key, path)
67
+ resources[key.to_sym] = path
68
+ end
69
+
70
+ # @param origin [String, nil] scheme and host the client used
71
+ # @return [Array<String>] every resource identifier this installation
72
+ # serves
73
+ def resource_identifiers(origin = nil)
74
+ base = origin.presence || application_origin
75
+ resources.values.map { |path| "#{base}#{path}" }
76
+ end
77
+
78
+ # @param key [Symbol]
79
+ # @param origin [String, nil] scheme and host the client used
80
+ # @return [String, nil]
81
+ def resource_identifier(key, origin = nil)
82
+ path = resources[key.to_sym]
83
+ return if path.nil?
84
+
85
+ "#{origin.presence || application_origin}#{path}"
86
+ end
87
+
88
+ # Where this application answers, from the URL options a deployment
89
+ # already has to set for mailer links to work.
90
+ #
91
+ # @return [String]
92
+ def application_origin
93
+ options = Rails.application.routes.default_url_options
94
+ host = options[:host]
95
+ return '' if host.blank?
96
+
97
+ origin = "#{options[:protocol].presence || 'https'}://#{host}"
98
+ options[:port].present? ? "#{origin}:#{options[:port]}" : origin
99
+ end
100
+
101
+ # The grantable scope list, re-read after host and extension
102
+ # initializers have run.
103
+ #
104
+ # `optional_scopes` builds its value eagerly, and an extension
105
+ # registers its scopes from a config initializer — which runs after
106
+ # {.configure!}, because a host's own `doorkeeper.rb` has to be able
107
+ # to win. Without this second pass the vocabulary would be whatever
108
+ # core shipped, and an extension's keys could never be granted.
109
+ def refresh_scopes!
110
+ ::Doorkeeper.config.instance_variable_set(
111
+ :@optional_scopes,
112
+ ::Doorkeeper::OAuth::Scopes.from_array(staff_scope_keys)
113
+ )
114
+ end
115
+
116
+ # Scopes that govern credentials rather than commerce, and so are
117
+ # never offered to a delegated client.
118
+ #
119
+ # Granting an agent `write_oauth_applications` would let it connect another
120
+ # agent; `write_api_keys` would let it mint a key that outlives the
121
+ # grant it was given. Neither is an escalation a merchant could
122
+ # reasonably be asked to judge on a consent screen, and the tools
123
+ # withhold the models anyway — so the scopes should not be offered in
124
+ # the first place, rather than offered and quietly inert.
125
+ CREDENTIAL_SCOPES = %w[oauth_applications api_keys integrations webhooks staff].freeze
126
+
127
+ # @return [Array<String>]
128
+ def staff_scope_keys
129
+ Spree.permissions.grantable_keys(Spree::PermissionConfiguration::STAFF_AUDIENCE).
130
+ reject { |key| CREDENTIAL_SCOPES.any? { |scope| key.to_s.end_with?("_#{scope}") } }
131
+ end
132
+
133
+ # Whether every requested audience names a resource this application
134
+ # actually serves.
135
+ #
136
+ # Compared by path rather than by full URL: the origin a client used
137
+ # is whatever it was pointed at, and a deployment behind a proxy or
138
+ # reachable at several hostnames would otherwise refuse its own
139
+ # tokens.
140
+ #
141
+ # @param requested [Array<String>] the `resource` values asked for
142
+ # @return [Boolean]
143
+ def indicators_valid?(requested, _client = nil)
144
+ return false if requested.blank?
145
+
146
+ paths = resources.values
147
+ requested.all? do |indicator|
148
+ path = URI.parse(indicator.to_s).path
149
+ paths.include?(path)
150
+ rescue URI::InvalidURIError
151
+ false
152
+ end
153
+ end
154
+
155
+ def configure!
156
+ ::Doorkeeper.configure do
157
+ orm :active_record
158
+
159
+ application_class 'Spree::OauthApplication'
160
+ access_grant_class 'Spree::OauthAccessGrant'
161
+ access_token_class 'Spree::OauthAccessToken'
162
+
163
+ # Consent is Spree's own, at
164
+ # `Spree::Api::V3::Admin::Oauth::AuthorizationsController`, and
165
+ # Doorkeeper's authorization controllers are not mounted — so
166
+ # this is only reached if a host app mounts them. Failing loudly
167
+ # beats a NoMethodError inside a before_action, which
168
+ # `handle_auth_errors :raise` would turn into a 500 on an
169
+ # authorization endpoint.
170
+ resource_owner_authenticator do
171
+ raise "Doorkeeper's own consent screens are not mounted in Spree. " \
172
+ 'Consent is served by the Admin API at /api/v3/admin/oauth/authorize.'
173
+ end
174
+
175
+ # An admin user today, a customer when a storefront client needs
176
+ # one — recorded polymorphically so either can own a grant.
177
+ use_polymorphic_resource_owner
178
+
179
+ # Authorization code only. A password grant asks a client to
180
+ # handle someone's password, and client credentials identify no
181
+ # person, so neither belongs on a surface whose authority is a
182
+ # user's own permissions.
183
+ grant_flows %w[authorization_code]
184
+
185
+ # Mandatory, not merely supported: these are public clients, and
186
+ # without PKCE an intercepted code is enough to obtain a token.
187
+ force_pkce
188
+ # S256 only — `plain` is in the gem's defaults and protects
189
+ # nothing against an intercepted code.
190
+ pkce_code_challenge_methods %w[S256]
191
+
192
+ # A redirect URI is where authorization codes are delivered, so a
193
+ # plain-http one hands them to anyone on the path — except a
194
+ # loopback one, which never leaves the machine. A terminal client
195
+ # listens on an ephemeral local port and has no certificate for
196
+ # it, so RFC 8252 §7.3 makes that the recommended shape, and
197
+ # refusing it would lock out Claude Code and Codex entirely.
198
+ #
199
+ # Passed as a callable so the exemption is per URI rather than
200
+ # per environment: everything else still has to be https.
201
+ force_ssl_in_redirect_uri do |uri|
202
+ next false if Rails.env.development?
203
+
204
+ !Spree::Api::Oauth.loopback_uri?(uri)
205
+ end
206
+
207
+ # RFC 8707. Registering the validator is what binds an audience
208
+ # into the token; left nil, the `resource` parameter is ignored
209
+ # and a token minted for another service would be accepted.
210
+ resource_indicator_validator(lambda do |requested, _client|
211
+ Spree::Api::Oauth.indicators_valid?(requested)
212
+ end)
213
+
214
+ # Spree's own permission catalog is the scope vocabulary, so a
215
+ # grant carries the same keys an API key would and the tool
216
+ # registry needs no translation. Seeded here and re-read in
217
+ # `refresh_scopes!` once extensions have registered theirs.
218
+ optional_scopes(*Spree::Api::Oauth.staff_scope_keys)
219
+
220
+ use_refresh_token
221
+ # One live token per authorization, so re-consenting does not
222
+ # leave the previous one usable.
223
+ revoke_previous_authorization_code_token
224
+ # No `fallback: :plain`: every token this server writes is
225
+ # hashed, so a plaintext lookup could never match and keeping
226
+ # the path alive only invites a future migration to rely on it.
227
+ hash_token_secrets
228
+ access_token_expires_in 2.hours
229
+ # Refresh rotation with replay detection comes from the
230
+ # `previous_refresh_token` column the migration adds — Doorkeeper
231
+ # enables it by the column's presence, not by a setting.
232
+
233
+ api_only
234
+ handle_auth_errors :raise
235
+ # Never from a query parameter: the spec forbids it, and a URL
236
+ # reaches logs and proxies.
237
+ access_token_methods :from_bearer_authorization
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
@@ -106,13 +106,20 @@ module Spree
106
106
  def attribute_rows(table)
107
107
  rows = ['| Attribute | Kind | Predicates |', '|---|---|---|']
108
108
  table.attributes.except(*table.aliased_attributes).each do |attribute, kind|
109
- label = kind.is_a?(Hash) ? "One of #{kind['enum'].map { |value| "`#{value}`" }.join(', ')}" : KIND_LABELS.fetch(kind)
109
+ label = kind_label(kind)
110
110
  predicates = Spree::Api::V3::FilterPredicates::BY_KIND.fetch(kind.is_a?(Hash) ? 'enum' : kind)
111
111
  rows << "| `#{attribute}` | #{label} | #{predicates.map { |predicate| "`#{predicate}`" }.join(' ')} |"
112
112
  end
113
113
  rows
114
114
  end
115
115
 
116
+ def kind_label(kind)
117
+ return KIND_LABELS.fetch(kind) unless kind.is_a?(Hash)
118
+ return "One of the `#{kind['code']}` codes" if kind['code']
119
+
120
+ "One of #{kind['enum'].map { |value| "`#{value}`" }.join(', ')}"
121
+ end
122
+
116
123
  def association_rows
117
124
  visible = ->(table) { table.attributes.keys - table.aliased_attributes }
118
125
  @table.associations.flat_map do |name, associated|
@@ -9,6 +9,11 @@ module Spree
9
9
  def self.call(serializer_dirs)
10
10
  serializer_dirs.each { |dir| Rails.autoloaders.main.eager_load_dir(dir.to_s) }
11
11
 
12
+ # Before the SDK types, so two serializers disagreeing on a list's
13
+ # values stop generation instead of one of them silently winning.
14
+ require Spree::Api::Engine.root.join('lib/spree/api/enum_types').to_s
15
+ Spree::Api::EnumTypes.new.write!(Spree::Api::Engine.root.join('../..'))
16
+
12
17
  require 'typelizer/generator'
13
18
  Typelizer::Generator.call(force: true)
14
19
 
data/lib/spree/api.rb CHANGED
@@ -3,6 +3,7 @@ require 'pagy'
3
3
  require 'typelizer'
4
4
  require 'typelizer/proc_resource_resolution'
5
5
  require 'typelizer/open_enums'
6
+ require 'spree/api/extension_fields'
6
7
 
7
8
  module Spree
8
9
  module Api
@@ -40,6 +41,13 @@ module Spree
40
41
  api_dependency?(base_name) || super
41
42
  end
42
43
 
44
+ # Fields extensions add to core serializers' responses.
45
+ #
46
+ # @return [Spree::Api::ExtensionFields]
47
+ def extension_fields
48
+ @extension_fields ||= Spree::Api::ExtensionFields.new
49
+ end
50
+
43
51
  # Direct access to raw dependencies object for backwards compatibility
44
52
  def dependencies
45
53
  Spree::Api::Dependencies
@@ -8,19 +8,51 @@ module Typelizer
8
8
  # from either still accepts an added value while every built-in value stays
9
9
  # documented and autocompletable.
10
10
  #
11
- # ISO lists are the exception: a named type several serializers and the
12
- # request types share, but closed, since nothing outside the standard can
13
- # add a country or a currency, and markets only accept the locales
14
- # `Spree::Locales::ALL` lists.
11
+ # Every named list is defined once, as values and type, in `@spree/core`, and
12
+ # each SDK's `Enums.ts` re-exports it through `@spree/sdk-core` instead of
13
+ # carrying a copy. {Spree::Api::EnumTypes} writes them there.
14
+ #
15
+ # A name in `CLOSED_LIST_NAMES` keeps its list closed — named only so it is
16
+ # defined once — and both contracts stay exactly what `enum:` alone gives.
17
+ # Shared code lists are closed too, but come from the API reference
18
+ # (`pnpm generate:code-lists`) rather than the serializers: nothing outside
19
+ # the standard can add a country, a currency or a unit, and markets only
20
+ # accept the locales `Spree::Locales::ALL` lists.
15
21
  module OpenEnums
16
- CLOSED_TYPE_NAMES = %w[CountryCode CurrencyCode LocaleCode].freeze
22
+ CLOSED_TYPE_NAMES = %w[CountryCode CurrencyCode LocaleCode WeightUnit DimensionUnit].freeze
23
+
24
+ CLOSED_LIST_NAMES = %w[
25
+ ApiKeyType CaptureMethod CatalogAssignableType CatalogPriceSource ClaimResolution
26
+ CollectionRuleMatchPolicy CollectionSortOrder CommissionKind CompanyInvitationStatus CompanyKind
27
+ ConsentSource DataRequestKind DeliveryStatus DeliveryZoneMemberType DiscountKind DocumentNumberFormat
28
+ EmailFont EmailTemplateKind FileUploadVisibility FulfillmentType MatchPolicy MediaType OptionTypeKind
29
+ OrderFulfillmentStatus OrderPaymentStatus OrderStatus PackageTypeKind PaymentSourceType
30
+ PayoutScheduleInterval PermissionKind PickupStockPolicy ProviderFailurePolicy PurchaseUnit
31
+ SellerRequirementState SellerTransferKind ShippingLabelFormat ShippingLabelOwnerType ShippingLabelSource
32
+ StockAvailability StockMovementKind StockReceiptLineType StockReceiptReceivableType
33
+ StockReceiptRejectionReason StorefrontAccess TaxIdentifierSource TaxIdentifierValidationStatus
34
+ TaxRemittance UnitSystem
35
+ ].freeze
36
+
37
+ # @param name [String, nil] a list's `enum_type_name`
38
+ # @return [Boolean] whether the list is closed: no value outside it is valid
39
+ def self.closed?(name)
40
+ CLOSED_TYPE_NAMES.include?(name) || CLOSED_LIST_NAMES.include?(name)
41
+ end
17
42
 
18
43
  module Property
19
44
  def enum_definition(...)
20
- definition = super
21
- return definition if CLOSED_TYPE_NAMES.include?(enum_type_name)
45
+ super && "type { #{enum_type_name} } from '@spree/sdk-core'"
46
+ end
47
+
48
+ # Typelizer skips rewriting a file whose digest is unchanged, and the
49
+ # digest covers a property's values but not the definition above. Folding
50
+ # the re-export in makes a checkout that still carries the old inline
51
+ # list regenerate.
52
+ def fingerprint
53
+ return super unless enum_type_name
22
54
 
23
- definition && "#{definition} | (string & {})"
55
+ [super, enum_definition].inspect
24
56
  end
25
57
  end
26
58
 
@@ -29,10 +61,16 @@ module Typelizer
29
61
 
30
62
  def inline_schema(definition, property, openapi_version:)
31
63
  return super unless property.enum_type_name && property.enum.is_a?(Array) && !property.multi
32
- return super if CLOSED_TYPE_NAMES.include?(property.enum_type_name)
64
+ return super if OpenEnums.closed?(property.enum_type_name)
33
65
 
34
66
  open = { anyOf: [{ type: :string, enum: property.enum.compact }, { type: :string }] }
35
- apply_nullable(open, property, openapi_version: openapi_version)
67
+ # OpenAPI 3.0 ignores `nullable` beside `anyOf`, which is where
68
+ # Typelizer puts it, so the free-string branch carries it instead.
69
+ if property.nullable && !v31?(openapi_version)
70
+ open[:anyOf].last[:nullable] = true
71
+ else
72
+ apply_nullable(open, property, openapi_version: openapi_version)
73
+ end
36
74
  apply_metadata(open, property)
37
75
  open[:description] ||= OPEN_ENUM_DESCRIPTION
38
76
  open