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,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Querying
4
+ class AttributeFilter < DiscoverableFilter
5
+ attr_accessor :value
6
+
7
+ def self.type
8
+ :attribute
9
+ end
10
+
11
+ def self.data_type
12
+ raise NotImplementedError
13
+ end
14
+
15
+ def self.definition(**)
16
+ PackAPI::Types::SimpleFilterDefinition.new(name: filter_name, data_type:)
17
+ end
18
+
19
+ def initialize(value)
20
+ super()
21
+ @value = value
22
+ end
23
+
24
+ def present?
25
+ !value.nil?
26
+ end
27
+
28
+ def to_h
29
+ { filter_name => value }
30
+ end
31
+
32
+ def apply_to(query)
33
+ query.add(query.build.where(to_h))
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Querying
4
+ class AttributeFilterFactory
5
+ include Enumerable
6
+
7
+ DATA_TYPE_REGEXP = /\S+$/
8
+ private_constant :DATA_TYPE_REGEXP
9
+
10
+ def self.attribute_filter_cache
11
+ @attribute_filter_cache ||= Concurrent::Map.new
12
+ end
13
+
14
+ attr_reader :attribute_map_class
15
+
16
+ def initialize(attribute_map_class)
17
+ @attribute_map_class = attribute_map_class
18
+ end
19
+
20
+ def from_api_type
21
+ filterable_attributes.each do |attribute_name, attribute_type|
22
+ yield define_filter(attribute_name, attribute_type)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def filterable_attributes
29
+ @filterable_attributes ||= attribute_map_class.api_type.filterable_attributes
30
+ end
31
+
32
+ def model_attribute_names
33
+ return @model_attribute_names if defined?(@model_attribute_names)
34
+
35
+ names = {}
36
+ filterable_attributes.each_key { |name| names[name] = name }
37
+ @model_attribute_names = attribute_map_class.model_attribute_keys(names).invert
38
+ end
39
+
40
+ def data_type(attribute_type)
41
+ return 'Bool' if attribute_type.name.end_with?('TrueClass | FalseClass')
42
+
43
+ attribute_type.name.include?(' | ') ?
44
+ attribute_type.name[DATA_TYPE_REGEXP] :
45
+ attribute_type.name
46
+ end
47
+
48
+ def define_filter(attribute_name, attribute_type)
49
+ filter_name = model_attribute_names[attribute_name]
50
+ data_type = data_type(attribute_type)
51
+ self.class
52
+ .attribute_filter_cache
53
+ .compute_if_absent(filter_name) { Concurrent::Map.new }
54
+ .compute_if_absent(data_type) do
55
+ Class.new(AttributeFilter) do
56
+ define_singleton_method(:filter_name) { filter_name }
57
+ define_singleton_method(:data_type) { data_type }
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Querying
4
+ ###
5
+ # This is a generic query that can be used to query any collection of ActiveRecord models and then
6
+ # expose the results as a collection of value objects.
7
+ #
8
+ # It allows paginated queries, as well as record iteration
9
+ # queries (using snapshot cursors). In order to enable record iteration mode, the query must be called with
10
+ # both a snapshot cursor and a primary key filter.
11
+ class CollectionQuery
12
+ attr_accessor :collection, :collection_key, :filter_factory, :default_sort, :paginator, :sort, :results
13
+
14
+ ###
15
+ # Create the query object.
16
+ #
17
+ # @ param [ActiveRecord::Relation] collection The collection to query
18
+ # @ param [Object] value_object_factory The factory to use to convert model objects to value objects
19
+ # @ param [String|Symbol|Hash|Arel] default_sort The default sort to use if none is specified in the query
20
+ # @ param [String|Symbol] collection_key Unique and indexed key to use for the collection.
21
+ # Used for tie-breaker sort criteria and drives the record id lookup when in record iteration mode.
22
+ def initialize(collection:, collection_key: nil, default_sort: nil)
23
+ @collection = collection
24
+ @collection_key = (collection_key || collection.primary_key).to_sym
25
+ @default_sort = default_sort
26
+ @filter_factory = FilterFactory.new
27
+ @filter_factory.use_default_filter = true
28
+ end
29
+
30
+ ###
31
+ # Perform the query.
32
+ #
33
+ # @param [String] cursor A pagination cursor referencing a page of records in a recordset
34
+ # @param [Integer|Symbol] per_page A count of how many items to include on each page, or :all to skip pagination
35
+ # @param [String|Symbol|Hash|Arel] sort ActiveRecord `order` arguments.
36
+ # @param [Hash] search Attribute/value pairs that define the bounds of the recordset using wildcard matching (attribute LIKE %value%)
37
+ # @param [Hash] filters the keys are names of filters, the values are arguments to the filters
38
+ def call(cursor: nil, per_page: nil, sort: nil, search: nil, filters: {})
39
+ record_id = filters.delete(collection_key) if cursor.present? && filters[collection_key].present?
40
+ build_paginator(cursor, filters, per_page, search, sort)
41
+ build_active_record_query(record_id)
42
+ end
43
+
44
+ def to_sql
45
+ @query.to_sql
46
+ end
47
+
48
+ def reset
49
+ @results = nil
50
+ @query = nil
51
+ @paginator = nil
52
+ @sort = nil
53
+ @current_page_snapshot_cursor = nil
54
+ end
55
+
56
+ def current_page_snapshot_cursor
57
+ @current_page_snapshot_cursor ||= PackAPI::Pagination::SnapshotPaginator.cursor_for_results(results,
58
+ table_name: collection.klass.table_name,
59
+ collection_key:)
60
+ end
61
+
62
+ private
63
+
64
+ def build_active_record_query(record_id)
65
+ @query = collection
66
+ if paginator.query&.fetch(:search, nil).present?
67
+ apply_search(search: paginator.query[:search])
68
+ end
69
+ if paginator.query&.fetch(:filters, nil).present?
70
+ apply_filters(filters: paginator.query[:filters])
71
+ end
72
+
73
+ @sort = paginator.sort
74
+ paginator.total_items = @query.count
75
+ @query = @query.order(paginator.sort)
76
+ .offset(paginator.offset)
77
+ .limit(paginator.limit)
78
+
79
+ if PackAPI::Pagination::SnapshotPaginator.generated?(paginator)
80
+ snapshot_paginator = PackAPI::Pagination::SnapshotPaginator.new(paginator)
81
+ @query = snapshot_paginator.apply_to(@query, record_id:)
82
+ @results = snapshot_paginator.results
83
+ @current_page_snapshot_cursor = snapshot_paginator.cursor
84
+ end
85
+
86
+ @results ||= @query.to_a
87
+ end
88
+
89
+ def build_paginator(cursor, filters, per_page, search, sort)
90
+ @paginator = PackAPI::Pagination::PaginatorBuilder.build do |builder|
91
+ cursor.present? ?
92
+ builder.set_cursor(cursor:) :
93
+ builder.set_params(sort: stable_sort(default_sort)) # set a default sort for a new query
94
+
95
+ builder.set_params(sort: stable_sort(sort.presence)) if sort.present?
96
+ builder.set_params(query: { filters: }) if filters.present?
97
+ builder.set_params(query: { search: }) if search.present?
98
+ builder.set_params(per_page:) if per_page.present?
99
+ end
100
+ end
101
+
102
+ def apply_search(search:)
103
+ search.keys.each_with_index do |column, index|
104
+ value = search[column]
105
+ @query = index.positive? ?
106
+ @query.or(collection.where("LOWER(#{column}) LIKE LOWER(?)", "%#{value}%")) :
107
+ @query.where("LOWER(#{column}) LIKE LOWER(?)", "%#{value}%")
108
+ end
109
+ end
110
+
111
+ def apply_filters(filters:)
112
+ filtered_query = ComposableQuery.new(@query)
113
+ filter_factory.create_filters(filters).each { |filter| filter.apply_to(filtered_query) }
114
+ @query = filtered_query.build
115
+ end
116
+
117
+ def stable_sort(sort)
118
+ return sort if sort.is_a?(Arel::Nodes::SqlLiteral)
119
+
120
+ SortHash.new(sort).tap do |sort_hash|
121
+ sort_hash[collection_key] = :asc unless sort_hash.key?(collection_key)
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Querying
4
+ class ComposableQuery
5
+ def initialize(initial_query)
6
+ @query = initial_query
7
+ end
8
+
9
+ def add(query_clause)
10
+ @query = @query.merge(query_clause)
11
+ self
12
+ end
13
+
14
+ def build
15
+ @query
16
+ end
17
+
18
+ def to_sql
19
+ @query.to_sql
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Querying
4
+ class DefaultFilter < AbstractFilter
5
+ attr_accessor :arguments
6
+
7
+ def initialize(arguments)
8
+ super()
9
+ @arguments = arguments
10
+ end
11
+
12
+ def present?
13
+ arguments.present?
14
+ end
15
+
16
+ def apply_to(query)
17
+ query.add(query.build.where(arguments))
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Querying
4
+ ##
5
+ # This class is the base class for all discoverable filters, which can produce a definition that specifies usage.
6
+ class DiscoverableFilter < AbstractFilter
7
+ include ActiveModel::Validations
8
+
9
+ def filter_name
10
+ self.class.filter_name
11
+ end
12
+
13
+ def self.filter_name
14
+ raise NotImplementedError
15
+ end
16
+
17
+ def type
18
+ self.class.type
19
+ end
20
+
21
+ def self.type
22
+ raise NotImplementedError
23
+ end
24
+
25
+ def to_h
26
+ raise NotImplementedError
27
+ end
28
+
29
+ def self.definition(**)
30
+ raise NotImplementedError
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Querying
4
+ module DynamicEnumFilter
5
+ FROZEN_EMPTY_ARRAY = [].freeze
6
+
7
+ extend ActiveSupport::Concern
8
+ class_methods do
9
+ def type = :dynamic_enum
10
+
11
+ def can_exclude? = false
12
+
13
+ def search_for_options(relation:, search:) = raise NotImplementedError
14
+
15
+ private
16
+
17
+ def filter_options(**) = FROZEN_EMPTY_ARRAY
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Querying
4
+ class FilterFactory
5
+ attr_accessor :filter_classes, :use_default_filter
6
+
7
+ def initialize
8
+ @filter_classes = Hash.new { |_hash, key| raise NotImplementedError, "Unsupported filter #{key}" }
9
+ end
10
+
11
+ def register_filter(name:, klass:)
12
+ filter_classes[name] = klass
13
+ end
14
+
15
+ def create_filters(filter_hash)
16
+ filter_objects(filter_hash).select(&:present?)
17
+ end
18
+
19
+ private
20
+
21
+ def filter_objects(filter_options)
22
+ if filter_options.is_a?(Hash)
23
+ filter_objects_from_hash(filter_options)
24
+ elsif use_default_filter
25
+ [DefaultFilter.new(filter_options)]
26
+ else
27
+ raise ArgumentError, "Unsupported filter configuration: #{filter_options}"
28
+ end
29
+
30
+ end
31
+
32
+ def filter_objects_from_hash(filter_options)
33
+ filter_options.deep_symbolize_keys.map(&method(:filter_object_by_name))
34
+ end
35
+
36
+ def filter_object_by_name(filter_name, options)
37
+ if filter_classes.key?(filter_name)
38
+ registered_filter_object(filter_name, options)
39
+ elsif use_default_filter
40
+ DefaultFilter.new(filter_name => options)
41
+ else
42
+ raise NotImplementedError, "Unsupported filter: #{filter_name}"
43
+ end
44
+ end
45
+
46
+ def registered_filter_object(filter_name, options)
47
+ options.is_a?(Hash) ?
48
+ filter_classes[filter_name].new(**options) :
49
+ filter_classes[filter_name].new(options)
50
+ rescue ArgumentError => e
51
+ raise ArgumentError, "Invalid filter options for #{filter_name}: #{options} (#{e})"
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Querying
4
+ class SortHash < Hash
5
+
6
+ ###
7
+ # Normalize a hash object to be used to control the sorting of a collection of objects
8
+ #
9
+ # @param [Hash|Symbol|String] sort_arg When provided with a string or a symbol, the sort hash will treat that
10
+ # as the name of the attribute to sort by, and will sort in ascending order. When provided with a hash, the keys
11
+ # of the hash should be the names of the attributes to sort by, and the values should be either `:asc` or `:desc`
12
+ def initialize(sort_arg)
13
+ super()
14
+ case sort_arg
15
+ when Arel::Nodes::SqlLiteral, nil
16
+ hash_entries = []
17
+ when Hash
18
+ hash_entries = sort_arg.to_a
19
+ when Symbol
20
+ hash_entries = [[sort_arg, :asc]]
21
+ when String
22
+ hash_entries = sort_arg.split(',').map do |sort_term|
23
+ sort_term_parts = sort_term.split
24
+ [sort_term_parts[0].to_sym, sort_term_parts[1]&.to_sym || :asc]
25
+ end
26
+ end
27
+
28
+ hash_entries.each do |key, value|
29
+ next unless key.present?
30
+
31
+ self[key] = value
32
+ end
33
+ deep_transform_values! { it.downcase.to_sym }
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Types
4
+ ###
5
+ # Define a type that blends attributes from multiple resources into a single data structure.
6
+ # For each kind of input resource, pass a block to the `combine_attributes` method, wherein are enumerated the
7
+ # attributes in the output data structure which originate from that input resource.
8
+ #
9
+ # Example:
10
+ #
11
+ # class MyAggregateType < PackAPI::Types::AggregateType
12
+ # combine_attributes from: :resource_one do
13
+ # attribute :id, Types::String
14
+ # attribute :name, Types::String
15
+ # end
16
+ # combine_attributes from: :resource_two do
17
+ # attribute :description, Types::String
18
+ # attribute :created_at, Types::Time
19
+ # end
20
+ # end
21
+ #
22
+ # This will create a type with attributes `id`, `name`, `description`, and `created_at`, where:
23
+ # - `id` and `name` come from `resource_one`
24
+ # - `description` and `created_at` come from `resource_two`
25
+ #
26
+ # For each input resource, helper methods must be defined to enable the CRUD operations:
27
+ # - query_<resource>s(**params) - returns a hash of resources, indexed by their IDs
28
+ # - get_<resource>(id) - returns a single resource, identified by its ID
29
+ # - create_<resource>(params) - creates a new resource and returns it
30
+ # - update_<resource>(id, params) - updates an existing resource and returns it
31
+ # - delete_<resource>(id)
32
+ #
33
+ # For example, for the above example, you would need to define:
34
+ # - query_resource_ones
35
+ # - get_resource_one
36
+ # - create_resource_one
37
+ # - update_resource_one
38
+ # - delete_resource_one
39
+ #
40
+ # @note: There are 2 kinds of resources:
41
+ # - primary resources
42
+ # - secondary resources
43
+ #
44
+ # The first resource drawn from via `combine_attributes` is considered the primary resource.
45
+ class AggregateType < BaseType
46
+ @attribute_sources = {}
47
+ @attribute_sources = {}
48
+ @next_attribute_list = nil
49
+
50
+ class << self
51
+ def inherited(subclass)
52
+ subclass.instance_variable_set(:@attribute_sources, {})
53
+ subclass.instance_variable_set(:@next_attribute_list, nil)
54
+ super
55
+ end
56
+
57
+ def attribute(name, type = Undefined, &block)
58
+ super
59
+ @next_attribute_list << name if @next_attribute_list
60
+ end
61
+
62
+ def combine_attributes(from:, &block)
63
+ @next_attribute_list = []
64
+ block.call
65
+ @attribute_sources[from] = @next_attribute_list
66
+ @next_attribute_list = nil
67
+ end
68
+
69
+ def query(**params)
70
+ attribute_blender.query(**params).map { new(it) }
71
+ end
72
+
73
+ def get(id)
74
+ new(attribute_blender.get(id))
75
+ end
76
+
77
+ def update(id, params)
78
+ new(attribute_blender.update(id, params))
79
+ end
80
+
81
+ def create(params)
82
+ new(attribute_blender.create(params))
83
+ end
84
+
85
+ def delete(id)
86
+ attribute_blender.delete(id)
87
+ end
88
+
89
+ private
90
+
91
+ def attribute_blender
92
+ @attribute_blender ||= AttributeBlender.new(@attribute_sources, self)
93
+ end
94
+ end
95
+
96
+ class AttributeBlender
97
+ attr_reader :attribute_sources, :combined_attributes, :aggregate_type
98
+
99
+ def initialize(attribute_sources, aggregate_type)
100
+ @attribute_sources = attribute_sources
101
+ @aggregate_type = aggregate_type
102
+ @combined_attributes = {}
103
+ end
104
+
105
+ def query(**params)
106
+ primary_source, primary_resource_attributes = attribute_sources.first
107
+ resource_method = :"query_#{primary_source}s"
108
+ primary_resource_results = aggregate_type.send(resource_method, **params)
109
+ combined_attributes = primary_resource_results.transform_values do |primary_resource|
110
+ primary_resource.to_h.slice(*primary_resource_attributes)
111
+ end
112
+ primary_result_ids = primary_resource_results.keys
113
+ attribute_sources.drop(1).each do |resource_name, resource_attributes|
114
+ resource_method = :"query_#{resource_name}s"
115
+ aggregate_type.send(resource_method, id: primary_result_ids)
116
+ .each do |primary_resource_id, secondary_resource|
117
+ secondary_attributes = secondary_resource.to_h.slice(*resource_attributes)
118
+ combined_attributes[primary_resource_id].merge!(secondary_attributes)
119
+ end
120
+ end
121
+
122
+ combined_attributes.values
123
+ end
124
+
125
+ def get(id)
126
+ attribute_sources.each do |resource_name, attribute_names|
127
+ resource_method = :"get_#{resource_name}"
128
+ resource = aggregate_type.send(resource_method, id)
129
+ combined_attributes.merge!(resource.to_h.slice(*attribute_names))
130
+ end
131
+ combined_attributes
132
+ end
133
+
134
+ def update(id, params)
135
+ initial_state = get(id)
136
+ @combined_attributes = initial_state.to_h
137
+ attribute_sources_updated = []
138
+ attribute_sources.each do |resource_name, resource_attributes|
139
+ resource_params = resource_params(params, resource_attributes)
140
+ next unless resource_params.any?
141
+
142
+ resource_method = :"update_#{resource_name}"
143
+ resource = aggregate_type.send(resource_method, id, resource_params)
144
+ combined_attributes.merge!(resource.to_h.slice(*resource_attributes))
145
+ end
146
+ combined_attributes
147
+ rescue PackAPI::InternalError
148
+ rollback_update(attribute_sources_updated, id, initial_state)
149
+ raise
150
+ end
151
+
152
+ def create(params)
153
+ attribute_sources_created = []
154
+ primary_resource_id = nil
155
+ attribute_sources.each do |resource_name, resource_attributes|
156
+ resource_method = :"create_#{resource_name}"
157
+ args = [resource_params(params, resource_attributes)]
158
+ args << primary_resource_id if primary_resource_id
159
+ resource = aggregate_type.send(resource_method, *args)
160
+ primary_resource_id ||= resource.id
161
+ combined_attributes.merge!(resource.to_h.slice(*resource_attributes))
162
+ end
163
+ combined_attributes
164
+ rescue PackAPI::InternalError
165
+ rollback_create(attribute_sources_created, primary_resource_id)
166
+ raise
167
+ end
168
+
169
+ def delete(id)
170
+ attribute_sources.each_key do |resource_name|
171
+ resource_method = :"delete_#{resource_name}"
172
+ aggregate_type.send(resource_method, id)
173
+ rescue PackAPI::InternalError => e
174
+ Rails.logger.error("Failed to delete #{resource_name} with id: #{id}")
175
+ Rails.logger.error(e.backtrace.join("\n"))
176
+ end
177
+ end
178
+
179
+ private
180
+
181
+ def resource_params(params, resource_attributes)
182
+ normalizer = PackAPI::Mapping::NormalizedAPIAttribute.new(resource_attributes)
183
+ params.filter { |key| resource_attributes.include?(normalizer.normalize(key)) }
184
+ end
185
+
186
+ def rollback_update(attribute_sources_updated, id, initial_state)
187
+ attribute_sources_updated.reverse_each do |resource_name|
188
+ resource_method = :"update_#{resource_name}"
189
+ attribute_names = attribute_sources[resource_name]
190
+ aggregate_type.send(resource_method, id, initial_state.to_h.slice(*attribute_names))
191
+ end
192
+ end
193
+
194
+ def rollback_create(attribute_sources_created, primary_resource_id)
195
+ attribute_sources_created.reverse_each do |resource_name|
196
+ resource_method = :"delete_#{resource_name}"
197
+ aggregate_type.send(resource_method, primary_resource_id)
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Types
4
+ class BaseType < Dry::Struct
5
+ @optional_attributes = []
6
+ @filterable_attributes = {}
7
+
8
+ def self.inherited(subclass)
9
+ subclass.instance_variable_set(:@optional_attributes, [])
10
+ super
11
+ end
12
+
13
+ def self.optional_attribute(name, type = Undefined, &block)
14
+ attribute?(name, type.optional, &block)
15
+ @optional_attributes << name
16
+ end
17
+
18
+ def self.optional_attributes
19
+ @optional_attributes.to_a
20
+ end
21
+
22
+ def to_data(**other_attributes)
23
+ merged_attributes = to_h.merge(other_attributes)
24
+ Data.define(*merged_attributes.keys).new(**merged_attributes)
25
+ end
26
+
27
+ def merge(**other_attributes)
28
+ self.class.new(to_h.merge(other_attributes))
29
+ end
30
+
31
+ ##
32
+ # This method returns a mapping of attribute names to `Dry::Type::*` objects that describe the attributes that have
33
+ # been designated as filterable, as in the following example:
34
+ # attribute :id, Types::String.meta(filterable: true)
35
+ #
36
+ # NOTE: According to the documentation, `Dry::Types::Schema::Key` is part of a private API, but we haven't found a
37
+ # better way to access an attribute's metadata or type:
38
+ # - https://www.rubydoc.info/github/dry-rb/dry-types/main/Dry/Types/Schema/Key
39
+ # - https://discourse.dry-rb.org/t/how-to-access-meta-fields-in-structs/989
40
+ def self.filterable_attributes
41
+ schema.keys.each_with_object({}) do |schema_key, result|
42
+ result[schema_key.name] = schema_key.type if schema_key.meta[:filterable]
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Types
4
+ class BooleanFilterDefinition < BaseType
5
+ attribute :name, Types::Symbol
6
+ attribute :type, Types::Symbol.default(:tri_state_boolean)
7
+ attribute :options, Types::Array.of(FilterOption)
8
+ end
9
+ end