pack_api 1.0.1 → 1.0.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f48730065ece9a8f8e18749b49d15d4f312c997148e86447f09ba8779bd5958d
4
- data.tar.gz: 5670802d6da87396caf59e961240dd2bb2d4dcc5b7bf5dafcc4ba83a196add72
3
+ metadata.gz: 246f379e82f7226306211a320a9f7ec9d7f9d251be0f2817c815a071b8159422
4
+ data.tar.gz: 939d5be377373d26a4e62ba7fb2870c595aadb52c7527f01c89c512d8af77856
5
5
  SHA512:
6
- metadata.gz: 2aaf0603ed13870e685757e6f17d8e3e98db880bb2bf0c31785940b0333eb1720cd84c5e15eb54854e6dce2dceb3d5d8780b91ef0c361fa14a498701928ee72f
7
- data.tar.gz: af308e82aee39b175b46e19c2ecaafab1a7eb12bb87cd072d2fd33bce808863c003f2b9796cf82329ebd3301cfb20ffd7b4f83145be72f6546a4d707d678037e
6
+ metadata.gz: 58ac5c88da900f602058aa151a38193bbda938baafd761380c1257079b7ded5e9b107c065dc251b4bc66e39e87fd1f6f623222798c9f50e3cb4dae251a02e6b2
7
+ data.tar.gz: eaf29a74517055cf5c97596da942d0d2f42197791b53931a2b59d67130e2afa2a6b0db8efa11a24d7c938279e2e111728440aabdef08e9a9282370ba390a001b
data/README.md CHANGED
@@ -228,6 +228,55 @@ def query_blog_posts(cursor = nil, search = nil, sort = nil, page_size = 50, fil
228
228
  end
229
229
  ```
230
230
 
231
+ ## Testing with Shared Examples
232
+
233
+ PackAPI includes RSpec shared examples to help test your API query methods. These are opt-in and only need to be loaded if you're using RSpec.
234
+
235
+ ### Loading Shared Examples
236
+
237
+ In your `spec_helper.rb` or `rails_helper.rb`, require the shared examples you need:
238
+
239
+ ```ruby
240
+ # Load all shared examples
241
+ require 'pack_api/rspec/shared_examples_for_api_query_methods'
242
+ require 'pack_api/rspec/shared_examples_for_paginated_results'
243
+
244
+ # Or load them individually as needed
245
+ require 'pack_api/rspec/shared_examples_for_api_query_methods'
246
+ ```
247
+
248
+ ### Using the Shared Examples
249
+
250
+ **Testing API Query Methods:**
251
+
252
+ ```ruby
253
+ RSpec.describe 'query_blog_posts' do
254
+ let(:api_query_method) { method(:query_blog_posts) }
255
+ let(:resources) { BlogPost.all }
256
+
257
+ it_behaves_like 'an API query method'
258
+
259
+ # With custom options
260
+ it_behaves_like 'an API query method',
261
+ model_id_attribute: :uuid,
262
+ supports_search: true do
263
+ let(:search_terms) { "searchable text" }
264
+ let(:matched_resources) { BlogPost.where("title LIKE ?", "%searchable%") }
265
+ end
266
+ end
267
+ ```
268
+
269
+ **Testing Paginated Methods:**
270
+
271
+ ```ruby
272
+ RSpec.describe 'paginated query' do
273
+ let(:paginated_api_query_method) { method(:query_blog_posts) }
274
+ let(:paginated_resources) { BlogPost.all }
275
+
276
+ it_behaves_like 'a paginated API method', model_id_attribute: :external_id
277
+ end
278
+ ```
279
+
231
280
  ## Development
232
281
 
233
282
  After checking out the repo, run:
@@ -0,0 +1,4 @@
1
+ class PackAPI::FrozenEmpty
2
+ ARRAY = [].freeze
3
+ HASH = {}.freeze
4
+ end
@@ -10,7 +10,7 @@ module PackAPI::Mapping
10
10
  @api_type = config[:api_type]
11
11
  @model_type = config[:model_type]
12
12
  @transform_value = config[:transform_value]
13
- @options = {}
13
+ @options = PackAPI::FrozenEmpty::HASH
14
14
  end
15
15
 
16
16
  ###
@@ -20,7 +20,7 @@ module PackAPI::Mapping
20
20
  end
21
21
 
22
22
  def options=(value)
23
- @options = value.presence || {}
23
+ @options = value.presence || PackAPI::FrozenEmpty::HASH
24
24
  end
25
25
 
26
26
  protected
@@ -30,8 +30,10 @@ module PackAPI::Mapping
30
30
  end
31
31
 
32
32
  def model_attribute(api_attribute)
33
+ # support _destroy and other special attributes used by Rails
33
34
  return api_attribute if api_attribute.start_with?('_')
34
35
 
36
+ # prevent api_attributes from being used unless they are in the public type definition
35
37
  unless mappings.key?(api_attribute)
36
38
  raise ActiveModel::UnknownAttributeError.new(@model_type.name, api_attribute)
37
39
  end
@@ -5,23 +5,16 @@ module PackAPI::Mapping
5
5
  # Specialized attribute transformer allowing API attributes be converted to the attribute names needed to
6
6
  # creating/updating an ActiveRecord model.
7
7
  class APIToModelAttributesTransformer < AbstractTransformer
8
-
9
8
  def execute
10
9
  result = {}
11
10
  attribute_names = NormalizedAPIAttribute.new(api_attribute_names)
12
11
  data_source.each do |api_attribute, api_value|
13
12
  normalized_api_attribute = attribute_names.normalize(api_attribute)
14
13
  model_attribute = model_attribute(normalized_api_attribute)
15
- model_value = model_value(normalized_api_attribute, api_value)
16
- result.deep_merge!({ model_attribute => model_value })
14
+ model_value = transform_value(normalized_api_attribute, api_value)
15
+ result[model_attribute] = model_value
17
16
  end
18
17
  result
19
18
  end
20
-
21
- private
22
-
23
- def model_value(api_attribute, api_value)
24
- transform_value(api_attribute, api_value)
25
- end
26
19
  end
27
20
  end
@@ -9,7 +9,6 @@ module PackAPI::Mapping
9
9
  # Converts model attributes to API attributes
10
10
  # Converts API attributes to model attributes
11
11
  class AttributeHashTransformer < AbstractTransformer
12
-
13
12
  def execute
14
13
  options.fetch(:contains_model_attributes, true) ?
15
14
  model_attributes_to_api_attributes :
@@ -26,8 +26,6 @@
26
26
  # associate the error with the "user_id" attribute (not the "user" attribute).
27
27
  module PackAPI::Mapping
28
28
  class AttributeMap
29
- FROZEN_EMPTY_HASH = {}.freeze
30
-
31
29
  attr_reader :config, :options
32
30
 
33
31
  class << self
@@ -225,25 +223,36 @@ module PackAPI::Mapping
225
223
  end
226
224
 
227
225
  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)
226
+ if @kwargs
227
+ proc ?
228
+ attribute_map.instance_exec(attribute_value, **@kwargs, &proc) :
229
+ attribute_map.send(instance_method.name, attribute_value, **@kwargs)
230
+ else
231
+ proc ?
232
+ attribute_map.instance_exec(attribute_value, &proc) :
233
+ attribute_map.send(instance_method.name, attribute_value)
234
+ end
231
235
  end
232
236
 
233
237
  def kwargs=(new_kwargs)
234
- @kwargs = supported_kwargs(new_kwargs)
238
+ @kwargs = new_kwargs == DEFAULT_OPTIONS || new_kwargs.blank? ?
239
+ nil :
240
+ supported_kwargs(new_kwargs)
235
241
  end
236
242
 
237
243
  private
238
244
 
239
245
  def supported_kwargs(kwargs)
240
- return FROZEN_EMPTY_HASH if kwargs.blank?
246
+ return if parameters.empty?
247
+
248
+ overlap = parameters.any? { |p| kwargs.key?(p) }
249
+ return unless overlap
241
250
 
242
- kwargs.select { |kwarg| parameters.any? { |parameter| parameter.last == kwarg } }
251
+ kwargs.slice(*parameters)
243
252
  end
244
253
 
245
254
  def parameters
246
- @parameters ||= (proc || instance_method).parameters
255
+ @parameters ||= (proc || instance_method).parameters.map(&:last)
247
256
  end
248
257
  end
249
258
 
@@ -1,9 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PackAPI::Mapping
4
- FROZEN_EMPTY_ARRAY = [].freeze
5
- FROZEN_EMPTY_HASH = {}.freeze
6
-
7
4
  ##
8
5
  # This class is responsible for transforming API filter names into model filters. It also produces filter definitions
9
6
  # in API terms for those filters that are supported by the model.
@@ -43,7 +40,7 @@ module PackAPI::Mapping
43
40
 
44
41
  def api_attribute_filter_names
45
42
  @api_attribute_filter_names ||= attribute_map_class.nil? ?
46
- FROZEN_EMPTY_ARRAY :
43
+ PackAPI::FrozenEmpty::ARRAY :
47
44
  attribute_map_class.api_type.filterable_attributes.keys
48
45
  end
49
46
 
@@ -51,7 +48,7 @@ module PackAPI::Mapping
51
48
  # Map from a backend filter name to an API filter name for default attribute filters.
52
49
  def api_attribute_filter_name_map
53
50
  return @api_attribute_filter_name_map if defined?(@api_attribute_filter_name_map)
54
- return FROZEN_EMPTY_HASH if attribute_map_class.nil?
51
+ return PackAPI::FrozenEmpty::HASH if attribute_map_class.nil?
55
52
 
56
53
  names = {}
57
54
  api_attribute_filter_names.each { |name| names[name] = name }
@@ -5,7 +5,6 @@ module PackAPI::Mapping
5
5
  # Specialized attribute transformer converting an ActiveRecord model attributes to the attribute names needed
6
6
  # to create a ValueObject in the public API.
7
7
  class ModelToAPIAttributesTransformer < AbstractTransformer
8
-
9
8
  def options=(options)
10
9
  super
11
10
  @optional_attributes_to_include = nil
@@ -17,15 +16,17 @@ module PackAPI::Mapping
17
16
  model_attribute = model_attribute(api_attribute)
18
17
  next unless include_model_attribute?(model_attribute)
19
18
 
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
19
+ api_value = transform_value(api_attribute, model_value(model_attribute)) if include_api_attribute?(api_attribute)
20
+ result[api_attribute] = api_value
24
21
  end
25
22
  end
26
23
 
27
24
  protected
28
25
 
26
+ def include_api_attribute?(api_attribute)
27
+ !optional_api_attribute?(api_attribute) || include_optional_api_attribute?(api_attribute)
28
+ end
29
+
29
30
  def model_attributes_of_interest
30
31
  @model_attributes_of_interest ||= options[:model_attributes_of_interest]
31
32
  end
@@ -38,7 +39,7 @@ module PackAPI::Mapping
38
39
  api_type_optional_attributes.include?(api_attribute_name)
39
40
  end
40
41
 
41
- def include_api_attribute?(api_attribute_name)
42
+ def include_optional_api_attribute?(api_attribute_name)
42
43
  return false if optional_attributes_to_include.nil?
43
44
  return false if optional_attributes_to_include.respond_to?(:exclude?) &&
44
45
  optional_attributes_to_include.exclude?(api_attribute_name)
@@ -53,15 +54,11 @@ module PackAPI::Mapping
53
54
  end
54
55
 
55
56
  def api_type_optional_attributes
56
- @api_type_optional_attributes ||= api_type.optional_attributes || []
57
+ @api_type_optional_attributes ||= api_type.optional_attributes || PackAPI::FrozenEmpty::ARRAY
57
58
  end
58
59
 
59
60
  def model_value(model_attribute)
60
61
  data_source.public_send(model_attribute)
61
62
  end
62
-
63
- def api_value(api_attribute, model_value)
64
- transform_value(api_attribute, model_value)
65
- end
66
63
  end
67
64
  end
@@ -2,7 +2,6 @@
2
2
 
3
3
  module PackAPI::Mapping
4
4
  class ValueObjectFactory
5
-
6
5
  class << self
7
6
  attr_reader :attribute_map_registry, :value_object_attributes
8
7
 
@@ -2,8 +2,6 @@
2
2
 
3
3
  module PackAPI::Querying
4
4
  module DynamicEnumFilter
5
- FROZEN_EMPTY_ARRAY = [].freeze
6
-
7
5
  extend ActiveSupport::Concern
8
6
  class_methods do
9
7
  def type = :dynamic_enum
@@ -14,7 +12,7 @@ module PackAPI::Querying
14
12
 
15
13
  private
16
14
 
17
- def filter_options(**) = FROZEN_EMPTY_ARRAY
15
+ def filter_options(**) = PackAPI::FrozenEmpty::ARRAY
18
16
  end
19
17
  end
20
18
  end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI
4
+ ###
5
+ # Assumes the following variables are defined:
6
+ # - api_query_method: the method to call
7
+ # - resources: the resources to compare against (must be more than 1)
8
+ #
9
+ # If the options contain a key :model_id_attribute, then the public id will be mapped to the given model attribute.
10
+ # Otherwise, it defaults to :external_id.
11
+ #
12
+ # If the options contain a key: `supports_search` set to `true`, then
13
+ # the following variables are also required:
14
+ # - search_terms: a string, representing the search terms to use
15
+ # - matched_resources: *at least 2* resources that will be returned by the search
16
+ RSpec.shared_examples 'an API query method' do |**options|
17
+ let(:model_id_attribute) { options[:model_id_attribute] || :external_id }
18
+
19
+ context 'with no id' do
20
+ it 'returns a successful result with all resources' do
21
+ # when
22
+ result = api_query_method.call
23
+
24
+ # then
25
+ expect(result.success).to be_truthy, -> { result.errors }
26
+ expect(result.errors).to be_nil
27
+ expect(result.value).not_to be_nil
28
+ expect(result.value).to have(resources.count).items
29
+ expect(result.value.pluck(:id).sort).to match_array(resources.pluck(model_id_attribute).sort)
30
+ end
31
+ end
32
+
33
+ context 'with nil id' do
34
+ it 'returns a successful result with all resources' do
35
+ # when
36
+ result = api_query_method.call(id: nil)
37
+
38
+ # then
39
+ expect(result.success).to be_truthy, -> { result.errors }
40
+ expect(result.errors).to be_nil
41
+ expect(result.value).not_to be_nil
42
+ expect(result.value).to have(resources.count).items
43
+ expect(result.value.pluck(:id).sort).to match_array(resources.pluck(model_id_attribute).sort)
44
+ end
45
+ end
46
+
47
+ context 'with an empty string id' do
48
+ it 'returns a successful result with no resources' do
49
+ # when
50
+ result = api_query_method.call(id: '')
51
+
52
+ # then
53
+ expect(result.success).to be_truthy, -> { result.errors }
54
+ expect(result.errors).to be_nil
55
+ expect(result.value).not_to be_nil
56
+ expect(result.value).to have(0).items
57
+ end
58
+ end
59
+
60
+ context 'with an empty array id' do
61
+ it 'returns a successful result with no resources' do
62
+ # when
63
+ result = api_query_method.call(id: [])
64
+
65
+ # then
66
+ expect(result.success).to be_truthy, -> { result.errors }
67
+ expect(result.errors).to be_nil
68
+ expect(result.value).not_to be_nil
69
+ expect(result.value).to have(0).items
70
+ end
71
+ end
72
+
73
+ context 'with an unknown id' do
74
+ it 'returns an empty success result' do
75
+ result = api_query_method.call(id: 'unknown')
76
+ expect(result.success).to be_truthy, -> { result.errors }
77
+ expect(result.value).to have(0).items
78
+ end
79
+ end
80
+
81
+ context 'with a known id' do
82
+ it 'returns a success result with the resource' do
83
+ value = api_query_method.call(id: resources.first[model_id_attribute])
84
+ expect(value.success).to be_truthy, -> { result.errors }
85
+ expect(value.value).to have(1).item
86
+ expect(value.value.first.id).to eq(resources.first[model_id_attribute])
87
+ end
88
+ end
89
+
90
+ it_behaves_like 'a paginated API method', model_id_attribute: options[:model_id_attribute] || :external_id do
91
+ let(:paginated_api_query_method) { api_query_method }
92
+ let(:paginated_resources) { resources }
93
+ end
94
+
95
+ context 'with search terms', if: options[:supports_search] do
96
+ it 'limits results to matching resources' do
97
+ # when
98
+ result = api_query_method.call(search_terms:)
99
+ # then
100
+ expect(result.success).to be_truthy, -> { result.errors }
101
+ expect(result.value).to have(matched_resources.size).item
102
+ matched_resource_ids = matched_resources.pluck(model_id_attribute)
103
+ result.value.each do |resource|
104
+ expect(matched_resource_ids).to include(resource.id)
105
+ end
106
+ end
107
+
108
+ it_behaves_like 'a paginated API method', model_id_attribute: options[:model_id_attribute] || :external_id do
109
+ let(:paginated_api_query_method) do
110
+ ->(**args) { api_query_method.call(search_terms:, **args) }
111
+ end
112
+ let(:paginated_resources) { matched_resources }
113
+ end
114
+ end
115
+
116
+ it 'supports sorting by API attributes only' do
117
+ resources.each_with_index { |resource, index| resource.update(model_id_attribute => format('%02d', index + 1)) }
118
+ # when - sort by API attribute `id` in descending order
119
+ results = api_query_method.call(sort: { id: :desc })
120
+ # then - results should be in model_id_attribute in descending order
121
+ expect(results.success).to be_truthy, -> { results.errors }
122
+ expect(results.value.first.id).to eq(resources.last[model_id_attribute])
123
+ expect(results.value.last.id).to eq(resources.first[model_id_attribute])
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI
4
+ ###
5
+ # Assumes the following variables are defined:
6
+ # - paginated_api_query_method: the method to call
7
+ # - paginated_resources: the resources to compare against (must be more than 1)
8
+ #
9
+ # If the options contain a key :model_id_attribute, then the public id will be mapped to the given model attribute.
10
+ # Otherwise, it defaults to :external_id.
11
+ # If the options contain a key :public_id_attribute, that will be used to access the resource identifier in the results.
12
+ RSpec.shared_examples 'a paginated API method' do |**options|
13
+ let(:public_id_attribute) { options[:public_id_attribute] || :id }
14
+ let(:model_id_attribute) { options[:model_id_attribute] || :external_id }
15
+
16
+ it 'can access the resources page-by-page' do
17
+ returned_object_ids = []
18
+
19
+ page_one_results = paginated_api_query_method.call(per_page: 1)
20
+ expect(page_one_results.success).to be_truthy, -> { page_one_results.errors }
21
+ expect(page_one_results.value).to have(1).item
22
+ expect(page_one_results.collection_metadata.next_page_cursor).not_to be_nil
23
+ returned_object_ids << page_one_results.value.first.send(public_id_attribute)
24
+ next_page_cursor = page_one_results.collection_metadata.next_page_cursor
25
+
26
+ (paginated_resources.count - 1).times do |index|
27
+ next_page_results = paginated_api_query_method.call(per_page: 1, cursor: next_page_cursor)
28
+ expect(next_page_results.success).to be_truthy, -> { next_page_results.errors }
29
+ expect(next_page_results.value).to have(1).item
30
+ if index < paginated_resources.count - 2 # if not last page
31
+ expect(next_page_results.collection_metadata.next_page_cursor).not_to be_nil, 'next page cursor should not be nil'
32
+ next_page_cursor = next_page_results.collection_metadata.next_page_cursor
33
+ else
34
+ expect(next_page_results.collection_metadata.next_page_cursor).to be_nil, 'next page cursor should be nil'
35
+ end
36
+ returned_object_ids << next_page_results.value.first.send(public_id_attribute)
37
+ end
38
+
39
+ # verify all objects were returned
40
+ expect(returned_object_ids.sort).to match_array(paginated_resources.pluck(model_id_attribute).sort)
41
+ end
42
+
43
+ it 'can access the metadata (without data)' do
44
+ results = paginated_api_query_method.call(per_page: 0)
45
+ expect(results.success).to be_truthy, -> { results.errors }
46
+ expect(results.value).to be_empty
47
+ expect(results.collection_metadata).not_to be_nil
48
+ expect(results.collection_metadata.total_items).to eq(paginated_resources.count)
49
+ end
50
+ end
51
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PackAPI
4
- VERSION = "1.0.1"
4
+ VERSION = "1.0.3"
5
5
  end
data/lib/pack_api.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "types"
9
9
  require_relative "pack_api/version"
10
10
 
11
11
  module PackAPI
12
+ autoload :FrozenEmpty, "pack_api/frozen_empty"
12
13
  autoload :InternalError, "pack_api/internal_error"
13
14
  autoload :ValuesInBatches, "pack_api/values_in_batches"
14
15
  autoload :ValuesInBackgroundBatches, "pack_api/values_in_background_batches"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pack_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Flytedesk
@@ -199,6 +199,7 @@ files:
199
199
  - LICENSE.txt
200
200
  - README.md
201
201
  - lib/pack_api.rb
202
+ - lib/pack_api/frozen_empty.rb
202
203
  - lib/pack_api/internal_error.rb
203
204
  - lib/pack_api/mapping/abstract_transformer.rb
204
205
  - lib/pack_api/mapping/api_to_model_attributes_transformer.rb
@@ -230,6 +231,8 @@ files:
230
231
  - lib/pack_api/querying/dynamic_enum_filter.rb
231
232
  - lib/pack_api/querying/filter_factory.rb
232
233
  - lib/pack_api/querying/sort_hash.rb
234
+ - lib/pack_api/rspec/shared_examples_for_api_query_methods.rb
235
+ - lib/pack_api/rspec/shared_examples_for_paginated_results.rb
233
236
  - lib/pack_api/types/aggregate_type.rb
234
237
  - lib/pack_api/types/base_type.rb
235
238
  - lib/pack_api/types/boolean_filter_definition.rb