pack_api 1.0.0

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 (54) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +39 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +238 -0
  5. data/lib/pack_api/config/dry_types_initializer.rb +1 -0
  6. data/lib/pack_api/models/internal_error.rb +25 -0
  7. data/lib/pack_api/models/mapping/abstract_transformer.rb +46 -0
  8. data/lib/pack_api/models/mapping/api_to_model_attributes_transformer.rb +27 -0
  9. data/lib/pack_api/models/mapping/attribute_hash_transformer.rb +46 -0
  10. data/lib/pack_api/models/mapping/attribute_map.rb +268 -0
  11. data/lib/pack_api/models/mapping/attribute_map_registry.rb +21 -0
  12. data/lib/pack_api/models/mapping/error_hash_to_api_attributes_transformer.rb +101 -0
  13. data/lib/pack_api/models/mapping/filter_map.rb +97 -0
  14. data/lib/pack_api/models/mapping/model_to_api_attributes_transformer.rb +67 -0
  15. data/lib/pack_api/models/mapping/normalized_api_attribute.rb +40 -0
  16. data/lib/pack_api/models/mapping/null_transformer.rb +9 -0
  17. data/lib/pack_api/models/mapping/value_object_factory.rb +83 -0
  18. data/lib/pack_api/models/pagination/opaque_token_v2.rb +19 -0
  19. data/lib/pack_api/models/pagination/paginator.rb +155 -0
  20. data/lib/pack_api/models/pagination/paginator_builder.rb +112 -0
  21. data/lib/pack_api/models/pagination/paginator_cursor.rb +86 -0
  22. data/lib/pack_api/models/pagination/snapshot_paginator.rb +133 -0
  23. data/lib/pack_api/models/querying/abstract_boolean_filter.rb +38 -0
  24. data/lib/pack_api/models/querying/abstract_enum_filter.rb +54 -0
  25. data/lib/pack_api/models/querying/abstract_filter.rb +15 -0
  26. data/lib/pack_api/models/querying/abstract_numeric_filter.rb +37 -0
  27. data/lib/pack_api/models/querying/abstract_range_filter.rb +31 -0
  28. data/lib/pack_api/models/querying/attribute_filter.rb +36 -0
  29. data/lib/pack_api/models/querying/attribute_filter_factory.rb +62 -0
  30. data/lib/pack_api/models/querying/collection_query.rb +125 -0
  31. data/lib/pack_api/models/querying/composable_query.rb +22 -0
  32. data/lib/pack_api/models/querying/default_filter.rb +20 -0
  33. data/lib/pack_api/models/querying/discoverable_filter.rb +33 -0
  34. data/lib/pack_api/models/querying/dynamic_enum_filter.rb +20 -0
  35. data/lib/pack_api/models/querying/filter_factory.rb +54 -0
  36. data/lib/pack_api/models/querying/sort_hash.rb +36 -0
  37. data/lib/pack_api/models/types/aggregate_type.rb +202 -0
  38. data/lib/pack_api/models/types/base_type.rb +46 -0
  39. data/lib/pack_api/models/types/boolean_filter_definition.rb +9 -0
  40. data/lib/pack_api/models/types/collection_result_metadata.rb +48 -0
  41. data/lib/pack_api/models/types/custom_filter_definition.rb +8 -0
  42. data/lib/pack_api/models/types/enum_filter_definition.rb +10 -0
  43. data/lib/pack_api/models/types/filter_option.rb +8 -0
  44. data/lib/pack_api/models/types/globally_identifiable.rb +19 -0
  45. data/lib/pack_api/models/types/numeric_filter_definition.rb +9 -0
  46. data/lib/pack_api/models/types/range_filter_definition.rb +10 -0
  47. data/lib/pack_api/models/types/result.rb +70 -0
  48. data/lib/pack_api/models/types/simple_filter_definition.rb +9 -0
  49. data/lib/pack_api/models/values_in_background_batches.rb +58 -0
  50. data/lib/pack_api/models/values_in_batches.rb +51 -0
  51. data/lib/pack_api/version.rb +5 -0
  52. data/lib/pack_api.rb +72 -0
  53. data/lib/types.rb +3 -0
  54. metadata +276 -0
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ ###
4
+ # Map (convert) the names of attributes presented on one side of an API to those required on the other side.
5
+ # Handles 3 scenarios:
6
+ #
7
+ # 1. create/update API endpoints
8
+ # IN: attribute Hash with names conforming to the ValueObject type attributes*
9
+ # OUT: attribute Hash with names conforming to the ActiveRecord model type
10
+ #
11
+ # * generally true; except when it's not. For example, if the ValueObject type has an attribute
12
+ # named "user", and the ActiveRecord model has an attribute named "user", typically this is set during
13
+ # create/update calls by passing in the "user_id" attribute.
14
+ #
15
+ # 2. converting an ActiveRecord model as a ValueObject
16
+ # IN: ActiveRecord model instance
17
+ # OUT: attribute Hash with names conforming to the ValueObject type attributes
18
+ #
19
+ # 3. converting an ActiveModel::Errors instance to an attribute Hash
20
+ # IN: ActiveModel::Errors instance
21
+ # OUT: attribute Hash with names conforming to the ValueObject type attributes*
22
+ #
23
+ # * generally true; except when it's not. For example, if the ValueObject type has an attribute
24
+ # named "user", and the ActiveRecord model has an attribute named "user", typically this is set during
25
+ # create/update calls by passing in the "user_id" attribute. Therefore, the error hash will have to
26
+ # associate the error with the "user_id" attribute (not the "user" attribute).
27
+ module PackAPI::Mapping
28
+ class AttributeMap
29
+ FROZEN_EMPTY_HASH = {}.freeze
30
+
31
+ attr_reader :config, :options
32
+
33
+ class << self
34
+ def map(source_attr, to: nil, from_api_attribute: nil, from_model_attribute: nil, readonly: nil, transform_nested_attributes_with: nil)
35
+ @mappings ||= {}
36
+ @from_api_attributes ||= {}
37
+ @from_model_attributes ||= {}
38
+ @transform_nested_attributes_with ||= {}
39
+ @mappings[source_attr] = to || source_attr
40
+ @from_model_attributes[source_attr] = from_model_attribute if from_model_attribute.present?
41
+ @from_api_attributes[source_attr] = from_api_attribute if from_api_attribute.present?
42
+ @transform_nested_attributes_with[source_attr] = transform_nested_attributes_with if transform_nested_attributes_with.present?
43
+ if readonly
44
+ @from_api_attributes[source_attr] = ->(*) { raise PackAPI::InternalError, "Unable to modify read-only attribute '#{source_attr}'" }
45
+ end
46
+ end
47
+
48
+ def api_type(api_type = nil)
49
+ return @api_type unless api_type
50
+
51
+ @api_type = api_type
52
+ end
53
+
54
+ def model_type(model_type = nil)
55
+ return @model_type unless model_type
56
+
57
+ @model_type = model_type
58
+ end
59
+
60
+ def config
61
+ {
62
+ mappings: @mappings,
63
+ from_api_attributes: @from_api_attributes,
64
+ from_model_attributes: @from_model_attributes,
65
+ transform_nested_attributes_with: @transform_nested_attributes_with,
66
+ api_type: @api_type,
67
+ model_type: @model_type
68
+ }
69
+ end
70
+ end
71
+
72
+ def self.model_attribute_keys(hash)
73
+ options = { contains_model_attributes: false,
74
+ transformer_type_for_source: AttributeHashTransformer.name }
75
+ new(hash.symbolize_keys, options).attributes
76
+ end
77
+
78
+ def self.api_attribute_keys(hash)
79
+ options = { contains_model_attributes: true,
80
+ transformer_type_for_source: AttributeHashTransformer.name }
81
+ new(hash.symbolize_keys, options).attributes
82
+ end
83
+
84
+ DEFAULT_OPTIONS = { optional_attributes: nil }.freeze
85
+ private_constant :DEFAULT_OPTIONS
86
+
87
+ def initialize(data_source = nil, options = nil)
88
+ @options = DEFAULT_OPTIONS
89
+ @config = self.class.config
90
+
91
+ self.options = options
92
+ self.data_source = data_source
93
+ end
94
+
95
+ def from_model_attributes
96
+ @from_model_attributes ||= config[:from_model_attributes].transform_values do |proc_or_method_name|
97
+ ValueTransformationChain.new([ValueTransformation.new(self.class, proc_or_method_name, options)])
98
+ end
99
+ end
100
+
101
+ def from_api_attributes
102
+ return @from_api_attributes if defined?(@from_api_attributes)
103
+
104
+ @from_api_attributes = config[:from_api_attributes].transform_values do |proc_or_method_name|
105
+ ValueTransformationChain.new([ValueTransformation.new(self.class, proc_or_method_name, options)])
106
+ end
107
+
108
+ config[:transform_nested_attributes_with].each do |source_attr, attribute_map_class|
109
+ @from_api_attributes[source_attr] ||= ValueTransformationChain.new([])
110
+ @from_api_attributes[source_attr].transformations << ValueTransformation.new(
111
+ self.class, :convert_nested_attribute, { attribute_map_class: attribute_map_class }
112
+ )
113
+ end
114
+
115
+ @from_api_attributes
116
+ end
117
+
118
+ def data_source=(data_source)
119
+ transformer_type = transformer_type_for_source(data_source)
120
+ unless @transformer.is_a?(transformer_type)
121
+ @transformer = transformer_type.new(config_for_adapter_type(transformer_type))
122
+ end
123
+ @transformer.data_source = data_source
124
+ @transformer.options = @options
125
+ end
126
+
127
+ def data_source
128
+ @transformer&.data_source
129
+ end
130
+
131
+ def options=(new_options)
132
+ return if new_options == @provided_options
133
+
134
+ @provided_options = new_options
135
+ @options = @provided_options ?
136
+ DEFAULT_OPTIONS.merge(@provided_options) :
137
+ DEFAULT_OPTIONS
138
+ from_model_attributes.each_value { |transformation| transformation.kwargs = options }
139
+ @transformer&.options = options
140
+ end
141
+
142
+ def register_transformation_from_model_attribute(source_attr, proc_or_method_name)
143
+ from_model_attributes[source_attr] ||= ValueTransformationChain.new([])
144
+ transformation = ValueTransformation.new(self.class, proc_or_method_name, options)
145
+ from_model_attributes[source_attr].transformations << transformation
146
+ end
147
+
148
+ def attributes
149
+ @transformer.execute
150
+ end
151
+
152
+ def api_type
153
+ self.class.api_type
154
+ end
155
+
156
+ def model_type
157
+ self.class.model_type
158
+ end
159
+
160
+ private
161
+
162
+ def convert_nested_attribute(parent_attribute_value, attribute_map_class:)
163
+ attribute_map = attribute_map_class.new
164
+ if parent_attribute_value.is_a?(Array)
165
+ parent_attribute_value.map do |nested_attributes|
166
+ attribute_map.data_source = nested_attributes
167
+ attribute_map.attributes
168
+ end
169
+ else
170
+ attribute_map.data_source = parent_attribute_value
171
+ attribute_map.attributes
172
+ end
173
+ end
174
+
175
+ def transformer_type_for_source(source)
176
+ if options.key?(:transformer_type_for_source)
177
+ options[:transformer_type_for_source].constantize
178
+ elsif source.nil?
179
+ NullTransformer
180
+ elsif source.is_a?(Hash)
181
+ APIToModelAttributesTransformer
182
+ elsif source.is_a?(ActiveModel::Errors)
183
+ ErrorHashToAPIAttributesTransformer
184
+ elsif source.is_a?(ActiveModel::AttributeAssignment)
185
+ ModelToAPIAttributesTransformer
186
+ else
187
+ raise "Unknown source #{source}"
188
+ end
189
+ end
190
+
191
+ def config_for_adapter_type(adapter_type)
192
+ if [ErrorHashToAPIAttributesTransformer, ModelToAPIAttributesTransformer].include?(adapter_type)
193
+ transform_value = method(:transform_value_for_api)
194
+ config.merge(transform_value:)
195
+ elsif adapter_type == APIToModelAttributesTransformer
196
+ transform_value = method(:transform_value_for_model)
197
+ config.merge(transform_value:)
198
+ else
199
+ config
200
+ end
201
+ end
202
+
203
+ def transform_value_for_api(api_attribute, model_value)
204
+ from_model_attributes.key?(api_attribute) ?
205
+ from_model_attributes[api_attribute].call(self, model_value) :
206
+ model_value
207
+ end
208
+
209
+ def transform_value_for_model(api_attribute, api_value)
210
+ from_api_attributes.key?(api_attribute) ?
211
+ from_api_attributes[api_attribute].call(self, api_value) :
212
+ api_value
213
+ end
214
+
215
+ class ValueTransformation
216
+ attr_reader :proc, :instance_method
217
+
218
+ def initialize(klass, proc_or_method_name, kwargs = nil)
219
+ if proc_or_method_name.is_a?(Proc)
220
+ @proc = proc_or_method_name
221
+ else
222
+ @instance_method = klass.instance_method(proc_or_method_name)
223
+ end
224
+ self.kwargs = kwargs
225
+ end
226
+
227
+ def call(attribute_map, attribute_value)
228
+ proc ?
229
+ attribute_map.instance_exec(attribute_value, **@kwargs, &proc) :
230
+ attribute_map.send(instance_method.name, attribute_value, **@kwargs)
231
+ end
232
+
233
+ def kwargs=(new_kwargs)
234
+ @kwargs = supported_kwargs(new_kwargs)
235
+ end
236
+
237
+ private
238
+
239
+ def supported_kwargs(kwargs)
240
+ return FROZEN_EMPTY_HASH if kwargs.blank?
241
+
242
+ kwargs.select { |kwarg| parameters.any? { |parameter| parameter.last == kwarg } }
243
+ end
244
+
245
+ def parameters
246
+ @parameters ||= (proc || instance_method).parameters
247
+ end
248
+ end
249
+
250
+ class ValueTransformationChain
251
+ attr_reader :transformations
252
+ def initialize(transformations)
253
+ @transformations = transformations
254
+ end
255
+
256
+ def kwargs=(new_kwargs)
257
+ transformations.each { it.kwargs = new_kwargs }
258
+ end
259
+
260
+ def call(attribute_map, attribute_value)
261
+ transformations.reduce(attribute_value) do |prev_result, next_transformation|
262
+ next_transformation.call(attribute_map, prev_result)
263
+ end
264
+ end
265
+ end
266
+
267
+ end
268
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Mapping
4
+ class AttributeMapRegistry
5
+
6
+ class << self
7
+ attr_reader :attribute_maps
8
+
9
+ def register_attribute_map(attribute_map_class)
10
+ @attribute_maps ||= {}
11
+ @attribute_maps[attribute_map_class.model_type] = attribute_map_class
12
+ end
13
+ end
14
+
15
+ def attribute_map_class(model_class)
16
+ raise "No attribute map defined for #{model_class}" unless self.class.attribute_maps.key?(model_class)
17
+
18
+ self.class.attribute_maps[model_class]
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Mapping
4
+ ###
5
+ # Specialized attribute transformer converting the attribute names in an ActiveRecord Error object to those
6
+ # that should be present in the Error Hash in the API Result object.
7
+ class ErrorHashToAPIAttributesTransformer < AbstractTransformer
8
+ NESTED_ATTRIBUTE_ERROR_KEY = /\A(?<parent>[\w_]+)\[(?<index>\d+)\]\.(?<child>[\w_]+)\z/
9
+
10
+ def initialize(config)
11
+ super
12
+ @transform_nested_attributes_with = config[:transform_nested_attributes_with]
13
+ end
14
+
15
+ def execute
16
+ api_attributes = api_attribute_names.flat_map do |api_attribute|
17
+ model_attribute = model_attribute(api_attribute)
18
+ next unless error_present_for?(model_attribute)
19
+
20
+ error_keys_for(model_attribute).map do |error_key|
21
+ converted_error_key = nested_attribute_error_key?(error_key) ?
22
+ convert_nested_attribute_error_key(error_key, api_attribute) :
23
+ normalize_association_reference(api_attribute, model_attribute)
24
+ [converted_error_key, data_source[error_key]]
25
+ end
26
+ end
27
+ api_attributes.compact.to_h
28
+ end
29
+
30
+ private
31
+
32
+ def convert_nested_attribute_error_key(error_key, parent_api_attribute)
33
+ NESTED_ATTRIBUTE_ERROR_KEY.match(error_key) do |match_data|
34
+ child_attribute_map_class = @transform_nested_attributes_with[parent_api_attribute]
35
+ child_model_attribute = match_data[:child].to_sym
36
+ child_api_attribute = child_model_attribute
37
+ if child_attribute_map_class
38
+ mapping = child_attribute_map_class.config[:mappings].find { |_k, v| v == child_model_attribute }
39
+ child_api_attribute = mapping.first if mapping
40
+ end
41
+ "#{parent_api_attribute}[#{match_data[:index]}].#{child_api_attribute}"
42
+ end
43
+ end
44
+
45
+ def nested_attribute_error_key?(error_key)
46
+ NESTED_ATTRIBUTE_ERROR_KEY.match?(error_key.to_s)
47
+ end
48
+
49
+ def error_keys_for(model_attribute)
50
+ data_source.include?(model_attribute) ?
51
+ [model_attribute] :
52
+ nested_attributes_with_errors(model_attribute)
53
+ end
54
+
55
+ def error_present_for?(model_attribute)
56
+ data_source.include?(model_attribute) || nested_attributes_with_errors(model_attribute).any?
57
+ end
58
+
59
+ def nested_attributes_with_errors(parent_attribute)
60
+ data_source.attribute_names.select { |attribute| attribute.to_s.start_with?("#{parent_attribute}[") }
61
+ end
62
+
63
+ def resource_association?(model_attribute)
64
+ resource_associations.include?(model_attribute)
65
+ end
66
+
67
+ def collection_association?(model_attribute)
68
+ collection_associations.include?(model_attribute)
69
+ end
70
+
71
+ def resource_associations
72
+ @resource_associations ||= model_type.reflect_on_all_associations
73
+ .reject(&:collection?)
74
+ .map(&:name)
75
+ end
76
+
77
+ def collection_associations
78
+ @collection_associations ||= model_type.reflect_on_all_associations
79
+ .select(&:collection?)
80
+ .map(&:name)
81
+ end
82
+
83
+ def normalize_association_reference(api_attribute, model_attribute)
84
+ if resource_association?(model_attribute)
85
+ normalize_resource_association_reference(api_attribute)
86
+ elsif collection_association?(model_attribute)
87
+ normalize_collection_association_reference(api_attribute)
88
+ else
89
+ api_attribute
90
+ end
91
+ end
92
+
93
+ def normalize_collection_association_reference(api_attribute)
94
+ :"#{api_attribute.to_s.singularize}_ids"
95
+ end
96
+
97
+ def normalize_resource_association_reference(api_attribute)
98
+ :"#{api_attribute.to_s.singularize}_id"
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Mapping
4
+ FROZEN_EMPTY_ARRAY = [].freeze
5
+ FROZEN_EMPTY_HASH = {}.freeze
6
+
7
+ ##
8
+ # This class is responsible for transforming API filter names into model filters. It also produces filter definitions
9
+ # in API terms for those filters that are supported by the model.
10
+ class FilterMap
11
+ attr_reader :filter_factory, :attribute_map_class
12
+
13
+ def initialize(filter_factory:, attribute_map_class: nil)
14
+ @filter_factory = filter_factory
15
+ @attribute_map_class = attribute_map_class
16
+ end
17
+
18
+ def from_api_filters(api_filters)
19
+ validate(api_filters)
20
+ transform_api_attribute_filters(api_filters).merge(transform_api_custom_filters(api_filters))
21
+ end
22
+
23
+ def filter_definitions(filter_names: nil, **)
24
+ supported_filters.filter_map do |filter_name, filter_class|
25
+ next if filter_names&.exclude?(filter_name)
26
+
27
+ api_attribute_filter_name_map.key?(filter_name) ?
28
+ attribute_filter_definition(filter_name, filter_class) :
29
+ other_filter_definition(filter_name, filter_class, **)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def attribute_filter_definition(filter_name, filter_class)
36
+ api_filter_name = api_attribute_filter_name_map[filter_name]
37
+ filter_class.definition.merge(name: api_filter_name)
38
+ end
39
+
40
+ def other_filter_definition(_filter_name, filter_class, **)
41
+ filter_class.definition(**)
42
+ end
43
+
44
+ def api_attribute_filter_names
45
+ @api_attribute_filter_names ||= attribute_map_class.nil? ?
46
+ FROZEN_EMPTY_ARRAY :
47
+ attribute_map_class.api_type.filterable_attributes.keys
48
+ end
49
+
50
+ ###
51
+ # Map from a backend filter name to an API filter name for default attribute filters.
52
+ def api_attribute_filter_name_map
53
+ return @api_attribute_filter_name_map if defined?(@api_attribute_filter_name_map)
54
+ return FROZEN_EMPTY_HASH if attribute_map_class.nil?
55
+
56
+ names = {}
57
+ api_attribute_filter_names.each { |name| names[name] = name }
58
+ @api_attribute_filter_name_map = attribute_map_class.model_attribute_keys(names)
59
+ end
60
+
61
+ def validate(filters)
62
+ invalid_filter_names = filters.keys.reject { |filter_name| valid?(filter_name) }
63
+ raise PackAPI::InternalError, validation_error_message(invalid_filter_names) if invalid_filter_names.any?
64
+ end
65
+
66
+ def valid?(filter_name)
67
+ api_filter_names.include?(filter_name)
68
+ end
69
+
70
+ def api_filter_names
71
+ @api_filter_names ||= supported_filters.keys.map do |filter_name|
72
+ api_attribute_filter_name_map.fetch(filter_name, filter_name)
73
+ end
74
+ end
75
+
76
+ def validation_error_message(unsupported_filter_names)
77
+ filter_names_string = unsupported_filter_names.map { |filter_name| "'#{filter_name}'" }.join(', ')
78
+ api_type_name = attribute_map_class.api_type.name
79
+ "unsupported #{'filter'.pluralize(unsupported_filter_names.size)} #{filter_names_string} for #{api_type_name}."
80
+ end
81
+
82
+ def transform_api_attribute_filters(api_filters)
83
+ return api_filters if attribute_map_class.nil?
84
+
85
+ api_attribute_filters = api_filters.select { |filter_name, _| api_attribute_filter_names.include?(filter_name) }
86
+ attribute_map_class.model_attribute_keys(api_attribute_filters)
87
+ end
88
+
89
+ def transform_api_custom_filters(api_filters)
90
+ api_filters.except(*api_attribute_filter_names)
91
+ end
92
+
93
+ def supported_filters
94
+ @supported_filters ||= filter_factory.filter_classes
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Mapping
4
+ ###
5
+ # Specialized attribute transformer converting an ActiveRecord model attributes to the attribute names needed
6
+ # to create a ValueObject in the public API.
7
+ class ModelToAPIAttributesTransformer < AbstractTransformer
8
+
9
+ def options=(options)
10
+ super
11
+ @optional_attributes_to_include = nil
12
+ @model_attributes_of_interest = nil
13
+ end
14
+
15
+ def execute
16
+ api_attribute_names.each_with_object({}) do |api_attribute, result|
17
+ model_attribute = model_attribute(api_attribute)
18
+ next unless include_model_attribute?(model_attribute)
19
+
20
+ value = unless optional_api_attribute?(api_attribute) && !include_api_attribute?(api_attribute)
21
+ api_value(api_attribute, model_value(model_attribute))
22
+ end
23
+ result[api_attribute] = value
24
+ end
25
+ end
26
+
27
+ protected
28
+
29
+ def model_attributes_of_interest
30
+ @model_attributes_of_interest ||= options[:model_attributes_of_interest]
31
+ end
32
+
33
+ def optional_attributes_to_include
34
+ @optional_attributes_to_include ||= options[:optional_attributes]
35
+ end
36
+
37
+ def optional_api_attribute?(api_attribute_name)
38
+ api_type_optional_attributes.include?(api_attribute_name)
39
+ end
40
+
41
+ def include_api_attribute?(api_attribute_name)
42
+ return false if optional_attributes_to_include.nil?
43
+ return false if optional_attributes_to_include.respond_to?(:exclude?) &&
44
+ optional_attributes_to_include.exclude?(api_attribute_name)
45
+
46
+ true
47
+ end
48
+
49
+ def include_model_attribute?(model_attribute)
50
+ return true if model_attributes_of_interest.blank?
51
+
52
+ model_attributes_of_interest.include?(model_attribute)
53
+ end
54
+
55
+ def api_type_optional_attributes
56
+ @api_type_optional_attributes ||= api_type.optional_attributes || []
57
+ end
58
+
59
+ def model_value(model_attribute)
60
+ data_source.public_send(model_attribute)
61
+ end
62
+
63
+ def api_value(api_attribute, model_value)
64
+ transform_value(api_attribute, model_value)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Mapping
4
+ class NormalizedAPIAttribute
5
+ PLURAL_IDS = '_ids'
6
+ SINGULAR_ID = '_id'
7
+
8
+ attr_reader :api_attribute_names
9
+
10
+ def initialize(api_attribute_names)
11
+ @api_attribute_names = api_attribute_names
12
+ end
13
+
14
+ def normalize(attribute_name)
15
+ if id_for_resource_association?(attribute_name)
16
+ normalize_resource_association_reference(attribute_name)
17
+ elsif id_for_collection_association?(attribute_name)
18
+ normalize_collection_association_reference(attribute_name)
19
+ else
20
+ attribute_name
21
+ end
22
+ end
23
+
24
+ def normalize_collection_association_reference(attribute_name)
25
+ :"#{attribute_name.to_s.delete_suffix(PLURAL_IDS)}s"
26
+ end
27
+
28
+ def id_for_collection_association?(attribute_name)
29
+ api_attribute_names.exclude?(attribute_name) && attribute_name.to_s.end_with?(PLURAL_IDS) # && api_attribute_names.include?(it_without_id_suffix)
30
+ end
31
+
32
+ def normalize_resource_association_reference(attribute_name)
33
+ attribute_name.to_s.delete_suffix(SINGULAR_ID).to_sym
34
+ end
35
+
36
+ def id_for_resource_association?(attribute_name)
37
+ api_attribute_names.exclude?(attribute_name) && attribute_name.to_s.end_with?(SINGULAR_ID) # && api_attribute_names.include?(it_without_id_suffix)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Mapping
4
+ class NullTransformer < AbstractTransformer
5
+ def execute
6
+ nil
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Mapping
4
+ class ValueObjectFactory
5
+
6
+ class << self
7
+ attr_reader :attribute_map_registry, :value_object_attributes
8
+
9
+ def set_attribute_map_registry(registry)
10
+ @attribute_map_registry = registry
11
+ @value_object_attributes ||= {}
12
+ end
13
+
14
+ def model_attributes_containing_value_objects(*attributes, model_class:)
15
+ @value_object_attributes ||= {}
16
+ @value_object_attributes[model_class] = attributes
17
+ end
18
+ end
19
+
20
+ def create_object(model:, optional_attributes: nil)
21
+ return nil if model.blank?
22
+
23
+ options = attribute_map_options_cache.compute_if_absent(optional_attributes) { { optional_attributes: } }
24
+ attribute_map = attribute_map(model.class, model, options)
25
+ attribute_map.api_type.new(attribute_map.attributes)
26
+
27
+ rescue Dry::Struct::Error => e
28
+ model_id = model.respond_to?(:id) ? "(id #{model.id})" : ''
29
+ raise PackAPI::InternalError, "Unable to convert #{model.class.name} #{model_id} to value object (#{e.message})"
30
+ end
31
+
32
+ def create_collection(models:, optional_attributes: nil)
33
+ return [] if models.blank?
34
+
35
+ models.filter_map { |model| create_object(model:, optional_attributes:) }
36
+ end
37
+
38
+ def create_errors(model:)
39
+ attribute_map(model.class, model.errors).attributes
40
+ end
41
+
42
+ protected
43
+
44
+ def attribute_map(klass, data_source, options = nil)
45
+ attribute_map_cache.compute_if_absent(klass) { create_attribute_map(klass) }.tap do |map|
46
+ map.data_source = data_source
47
+ map.options = options
48
+ end
49
+ end
50
+
51
+ def convert(value, optional_attributes: nil)
52
+ value_is_collection?(value) ?
53
+ create_collection(models: value.to_a, optional_attributes:) :
54
+ create_object(model: value, optional_attributes:)
55
+ end
56
+
57
+ def value_is_collection?(value)
58
+ value.is_a?(ActiveRecord::Associations::CollectionProxy) || value.is_a?(Array)
59
+ end
60
+
61
+ def create_attribute_map(model_class)
62
+ attribute_map_class = self.class.attribute_map_registry.new.attribute_map_class(model_class)
63
+ convert_proc = method(:convert).to_proc
64
+ attribute_map_class.new.tap do |attribute_map|
65
+ self.class.value_object_attributes.fetch(model_class, []).each do |attribute|
66
+ attribute_map.register_transformation_from_model_attribute(attribute, convert_proc)
67
+ end
68
+ end
69
+ end
70
+
71
+ def attribute_map_cache
72
+ object_cache.compute_if_absent(:attribute_maps) { Concurrent::Map.new }
73
+ end
74
+
75
+ def object_cache
76
+ @object_cache ||= Concurrent::Map.new
77
+ end
78
+
79
+ def attribute_map_options_cache
80
+ object_cache.compute_if_absent(:attribute_map_options) { Concurrent::Map.new }
81
+ end
82
+ end
83
+ end