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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +39 -0
- data/LICENSE.txt +21 -0
- data/README.md +238 -0
- data/lib/pack_api/config/dry_types_initializer.rb +1 -0
- data/lib/pack_api/models/internal_error.rb +25 -0
- data/lib/pack_api/models/mapping/abstract_transformer.rb +46 -0
- data/lib/pack_api/models/mapping/api_to_model_attributes_transformer.rb +27 -0
- data/lib/pack_api/models/mapping/attribute_hash_transformer.rb +46 -0
- data/lib/pack_api/models/mapping/attribute_map.rb +268 -0
- data/lib/pack_api/models/mapping/attribute_map_registry.rb +21 -0
- data/lib/pack_api/models/mapping/error_hash_to_api_attributes_transformer.rb +101 -0
- data/lib/pack_api/models/mapping/filter_map.rb +97 -0
- data/lib/pack_api/models/mapping/model_to_api_attributes_transformer.rb +67 -0
- data/lib/pack_api/models/mapping/normalized_api_attribute.rb +40 -0
- data/lib/pack_api/models/mapping/null_transformer.rb +9 -0
- data/lib/pack_api/models/mapping/value_object_factory.rb +83 -0
- data/lib/pack_api/models/pagination/opaque_token_v2.rb +19 -0
- data/lib/pack_api/models/pagination/paginator.rb +155 -0
- data/lib/pack_api/models/pagination/paginator_builder.rb +112 -0
- data/lib/pack_api/models/pagination/paginator_cursor.rb +86 -0
- data/lib/pack_api/models/pagination/snapshot_paginator.rb +133 -0
- data/lib/pack_api/models/querying/abstract_boolean_filter.rb +38 -0
- data/lib/pack_api/models/querying/abstract_enum_filter.rb +54 -0
- data/lib/pack_api/models/querying/abstract_filter.rb +15 -0
- data/lib/pack_api/models/querying/abstract_numeric_filter.rb +37 -0
- data/lib/pack_api/models/querying/abstract_range_filter.rb +31 -0
- data/lib/pack_api/models/querying/attribute_filter.rb +36 -0
- data/lib/pack_api/models/querying/attribute_filter_factory.rb +62 -0
- data/lib/pack_api/models/querying/collection_query.rb +125 -0
- data/lib/pack_api/models/querying/composable_query.rb +22 -0
- data/lib/pack_api/models/querying/default_filter.rb +20 -0
- data/lib/pack_api/models/querying/discoverable_filter.rb +33 -0
- data/lib/pack_api/models/querying/dynamic_enum_filter.rb +20 -0
- data/lib/pack_api/models/querying/filter_factory.rb +54 -0
- data/lib/pack_api/models/querying/sort_hash.rb +36 -0
- data/lib/pack_api/models/types/aggregate_type.rb +202 -0
- data/lib/pack_api/models/types/base_type.rb +46 -0
- data/lib/pack_api/models/types/boolean_filter_definition.rb +9 -0
- data/lib/pack_api/models/types/collection_result_metadata.rb +48 -0
- data/lib/pack_api/models/types/custom_filter_definition.rb +8 -0
- data/lib/pack_api/models/types/enum_filter_definition.rb +10 -0
- data/lib/pack_api/models/types/filter_option.rb +8 -0
- data/lib/pack_api/models/types/globally_identifiable.rb +19 -0
- data/lib/pack_api/models/types/numeric_filter_definition.rb +9 -0
- data/lib/pack_api/models/types/range_filter_definition.rb +10 -0
- data/lib/pack_api/models/types/result.rb +70 -0
- data/lib/pack_api/models/types/simple_filter_definition.rb +9 -0
- data/lib/pack_api/models/values_in_background_batches.rb +58 -0
- data/lib/pack_api/models/values_in_batches.rb +51 -0
- data/lib/pack_api/version.rb +5 -0
- data/lib/pack_api.rb +72 -0
- data/lib/types.rb +3 -0
- 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,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
|