pack_api 1.0.17 → 1.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d2ad9f8208ab7d473868cdfb1f5ea2c1a1331c8312cb24a41d105e2a1601d5c5
4
- data.tar.gz: f96b4024f722cbec40c1a2846a6d11b6dcfcf1779dea45c32b256079ed5f0834
3
+ metadata.gz: 62245994a5ffa0d342e05ee798356a0c6d61063a513accacd43195ab74e39416
4
+ data.tar.gz: 8be813a9fc9f0dcbdec802796cc632c680ae1273d104acc46f1c401da21ede88
5
5
  SHA512:
6
- metadata.gz: 73ddd88c532c6dd6357ba55a2783272390b3841a04f1e9b36bb1a833d5ba9a013b855a54b949a808e6c711ef0a5c63ccbe7c519b8ef70b067bbfe42534886213
7
- data.tar.gz: 3b943634599761edb3abdb651ba02ca7e679770b39ffb6a1d9197440c5064703bbf2147fa492aaca60ae95fc63da3eb7f9307836ad06674b63e979e692ae84c4
6
+ metadata.gz: 53c8cdb641ca26ef408e57e2fffa5e7d414f96f3f59a5ae030b18912b86c21bf85dd76ac53dc5c8ef38f42b4f58ceeec007d08f9e7eaba41634f4ab1a6ed08fb
7
+ data.tar.gz: 6a2dfbf4e5914dacc384f4206fbd207f85f149df6cfc8d9ac2a0c8599566b0bdac866f2fa56396a10b8ec6446c2939d26145a1ce3477b8e8f23c88dd78fb3451
data/README.md CHANGED
@@ -57,7 +57,7 @@ gem install pack_api
57
57
  The mapping module provides tools for transforming data between domain models and API representations:
58
58
 
59
59
  - `AttributeMap` - Define bidirectional mappings between model and API attributes
60
- - `AttributeMapRegistry` - Centralized registry for attribute mappings
60
+ - `AttributeMapRegistry` - Finds the attribute map for a model, by naming convention or explicit registration
61
61
  - `ModelToAPIAttributesTransformer` - Transform model attributes to API format
62
62
  - `APIToModelAttributesTransformer` - Transform API attributes to model format
63
63
  - `ValueObjectFactory` - Create value objects from raw data
@@ -141,9 +141,8 @@ end
141
141
  class AuthorAttributeMap < PackAPI::Mapping::AttributeMap
142
142
  api_type AuthorType
143
143
  model_type Author
144
- map :name, to: :name
145
144
  map :id, to: :external_id
146
- map :blog_posts
145
+ # name and blog_posts share their names with the model attributes, so they need no map
147
146
  end
148
147
 
149
148
  # api/comment_attribute_map.rb
@@ -158,17 +157,16 @@ class BlogPostAttributeMap < PackAPI::Mapping::AttributeMap
158
157
  api_type BlogPostType
159
158
  model_type BlogPost
160
159
 
161
- # example API attribute mapped to a model attribute of the same name
162
- map :title
160
+ # API attributes with the same name as the model attribute (title) need no map
161
+
162
+ # example of API attribute ending in "_id" (mapped explicitly for documentation; the default would be identical)
163
+ map :legacy_id
163
164
 
164
165
  map :contents, from_model_attribute: ->(attachment) { attachment&.blob }
165
166
 
166
167
  # example API attribute mapped to a model attribute of a different name
167
168
  map :id, to: :external_id
168
169
 
169
- # example of API attribute ending in "_id"
170
- map :legacy_id
171
-
172
170
  # example of API attribute mapped to a model method (unidirectional)
173
171
  map :persisted, to: :persisted?, readonly: true
174
172
 
@@ -185,7 +183,36 @@ end
185
183
 
186
184
  ```
187
185
 
188
- 3. Implement filters.
186
+ 3. Implement filters and register them in a filter factory. Attributes marked `filterable: true` on the value object
187
+ type get an `AttributeFilter` each via `register_attribute_filters`; anything else is a filter class of its own:
188
+
189
+ ```ruby
190
+ # models/filters/blog_post/filter_factory.rb
191
+ module Filters::BlogPost
192
+ class FilterFactory < PackAPI::Querying::FilterFactory
193
+ def initialize
194
+ super
195
+ register_attribute_filters(BlogPostAttributeMap)
196
+ register_filter(AuthorFilter) # keyed by AuthorFilter.filter_name
197
+ end
198
+ end
199
+ end
200
+
201
+ # api/blog_post_filter_map.rb
202
+ # resolves Filters::BlogPost::FilterFactory and BlogPostAttributeMap from its own name
203
+ class BlogPostFilterMap < PackAPI::Mapping::FilterMap; end
204
+ ```
205
+
206
+ The registry and value object factory follow the same pattern, so they are usually empty classes too
207
+ (see [Naming Conventions](#naming-conventions)):
208
+
209
+ ```ruby
210
+ # api/attribute_map_registry.rb
211
+ class AttributeMapRegistry < PackAPI::Mapping::AttributeMapRegistry; end
212
+
213
+ # api/value_object_factory.rb
214
+ class ValueObjectFactory < PackAPI::Mapping::ValueObjectFactory; end
215
+ ```
189
216
 
190
217
  4. Implement a query endpoint using the attribute map:
191
218
 
@@ -228,6 +255,35 @@ def query_blog_posts(cursor = nil, search = nil, sort = nil, page_size = 50, fil
228
255
  end
229
256
  ```
230
257
 
258
+ ## Naming Conventions
259
+
260
+ PackAPI infers collaborators from class names so that the classes a pack needs are mostly declarations of intent
261
+ with empty bodies. Every convention is a **default**, not a requirement: pass the argument or make the registration
262
+ explicitly and the convention is never consulted. A project that names things differently keeps working unchanged.
263
+
264
+ Given a pack namespace `Blogging` and a resource `Post`:
265
+
266
+ | Class | Infers | Explicit alternative |
267
+ |---|---|---|
268
+ | `Blogging::PostFilterMap < Mapping::FilterMap` | `filter_factory` = `Blogging::Filters::Post::FilterFactory.new`<br>`attribute_map_class` = `Blogging::PostAttributeMap` (nil when absent) | `super(filter_factory: ..., attribute_map_class: ...)` in `initialize` |
269
+ | `Blogging::AttributeMapRegistry < Mapping::AttributeMapRegistry` | attribute map for model `Blogging::Post` = `Blogging::PostAttributeMap`<br>nested models resolve to nested maps: `Blogging::Post::Draft` -> `Blogging::PostAttributeMap::Draft` | `register_attribute_map(SomeAttributeMap)`; an explicit registration always wins over the convention |
270
+ | `Blogging::ValueObjectFactory < Mapping::ValueObjectFactory` | `attribute_map_registry` = `Blogging::AttributeMapRegistry` | `set_attribute_map_registry(SomeRegistry)` |
271
+ | any `Mapping::AttributeMap` with an `api_type` | an identity mapping (`map :title`) for every attribute of the api type | `map :title, to: :headline` (or any other `map` option) overrides the identity mapping for that attribute |
272
+ | `Querying::FilterFactory#register_filter(klass)` | filter name = `klass.filter_name` | `register_filter(klass, name: :other)` or `register_filter(name:, klass:)` |
273
+ | `Querying::FilterFactory#register_attribute_filters(attribute_map_class)` | one `AttributeFilter` per attribute marked `filterable: true` on the api type | register attribute filters by hand |
274
+
275
+ Notes:
276
+
277
+ - The identity mapping has no opt-out because it never changes behaviour: an api attribute without a mapping was
278
+ already rejected as an `unknown attribute` on both read and write, so any working attribute map mapped every api
279
+ attribute explicitly. The default only fills those mandatory entries.
280
+ - Several api attributes may map to one model attribute (a rename next to the identity mapping, e.g.
281
+ `map :filename, to: :file` beside `file`). A nested attribute error on that model attribute is reported on the
282
+ api attribute with the same name when there is one, otherwise on the first mapping declared for it.
283
+ - Conventions resolve constants lazily, on first use, so autoloading (Zeitwerk) works without eager registration.
284
+ - A namespace is the class name minus its last segment (`Blogging::PostFilterMap` -> `Blogging`). Top-level classes
285
+ resolve top-level collaborators (`PostFilterMap` -> `Filters::Post::FilterFactory`).
286
+
231
287
  ## Testing with Shared Examples
232
288
 
233
289
  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.
@@ -56,15 +56,25 @@ module PackAPI::Mapping
56
56
  end
57
57
 
58
58
  def config
59
+ mappings = identity_mappings.merge(@mappings || {})
59
60
  {
60
- mappings: @mappings,
61
- from_api_attributes: @from_api_attributes,
62
- from_model_attributes: @from_model_attributes,
63
- transform_nested_attributes_with: @transform_nested_attributes_with,
61
+ mappings:,
62
+ from_api_attributes: @from_api_attributes || {},
63
+ from_model_attributes: @from_model_attributes || {},
64
+ transform_nested_attributes_with: @transform_nested_attributes_with || {},
64
65
  api_type: @api_type,
65
66
  model_type: @model_type
66
67
  }
67
68
  end
69
+
70
+ private
71
+
72
+ # every api type attribute maps to a model attribute of the same name
73
+ def identity_mappings
74
+ return {} unless @api_type
75
+
76
+ @api_type.attribute_names.index_with(&:itself)
77
+ end
68
78
  end
69
79
 
70
80
  def self.model_attribute_keys(hash)
@@ -1,21 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PackAPI::Mapping
4
+ ###
5
+ # Finds the attribute map for a model class. A registry named "<Namespace>::AttributeMapRegistry" resolves
6
+ # "<Namespace>::<Model>AttributeMap" by convention (nested models resolve to nested attribute maps, e.g.
7
+ # <Namespace>::Post::Draft -> <Namespace>::PostAttributeMap::Draft); register_attribute_map covers the exceptions.
4
8
  class AttributeMapRegistry
5
9
 
6
10
  class << self
7
- attr_reader :attribute_maps
11
+ def attribute_maps
12
+ @attribute_maps ||= {}
13
+ end
8
14
 
9
15
  def register_attribute_map(attribute_map_class)
10
- @attribute_maps ||= {}
11
- @attribute_maps[attribute_map_class.model_type] = attribute_map_class
16
+ attribute_maps[attribute_map_class.model_type] = attribute_map_class
12
17
  end
13
18
  end
14
19
 
15
20
  def attribute_map_class(model_class)
16
- raise "No attribute map defined for #{model_class}" unless self.class.attribute_maps.key?(model_class)
21
+ self.class.attribute_maps[model_class] ||
22
+ conventional_attribute_map_class(model_class) ||
23
+ raise("No attribute map defined for #{model_class}")
24
+ end
25
+
26
+ private
17
27
 
18
- self.class.attribute_maps[model_class]
28
+ def conventional_attribute_map_class(model_class)
29
+ namespace = self.class.name.deconstantize
30
+ model, *nested = model_class.name.delete_prefix("#{namespace}::").split('::')
31
+ [namespace.presence, "#{model}AttributeMap", *nested].compact.join('::').safe_constantize
19
32
  end
20
33
  end
21
34
  end
@@ -35,13 +35,19 @@ module PackAPI::Mapping
35
35
  child_model_attribute = match_data[:child].to_sym
36
36
  child_api_attribute = child_model_attribute
37
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
38
+ child_api_attribute = api_attribute_for(child_attribute_map_class.config[:mappings], child_model_attribute)
40
39
  end
41
40
  "#{parent_api_attribute}[#{match_data[:index]}].#{child_api_attribute}"
42
41
  end
43
42
  end
44
43
 
44
+ # several api attributes may map to one model attribute (e.g. a rename next to the identity mapping);
45
+ # the error belongs to the api attribute named like the model attribute when there is one
46
+ def api_attribute_for(mappings, model_attribute)
47
+ candidates = mappings.filter_map { |api_attribute, mapped| api_attribute if mapped == model_attribute }
48
+ candidates.find { |api_attribute| api_attribute == model_attribute } || candidates.first || model_attribute
49
+ end
50
+
45
51
  def nested_attribute_error_key?(error_key)
46
52
  NESTED_ATTRIBUTE_ERROR_KEY.match?(error_key.to_s)
47
53
  end
@@ -4,10 +4,13 @@ module PackAPI::Mapping
4
4
  ##
5
5
  # This class is responsible for transforming API filter names into model filters. It also produces filter definitions
6
6
  # in API terms for those filters that are supported by the model.
7
+ #
8
+ # A subclass named "<Namespace>::<Resource>FilterMap" resolves its collaborators by convention:
9
+ # "<Namespace>::Filters::<Resource>::FilterFactory" and "<Namespace>::<Resource>AttributeMap" (if it exists).
7
10
  class FilterMap
8
11
  attr_reader :filter_factory, :attribute_map_class
9
12
 
10
- def initialize(filter_factory:, attribute_map_class: nil)
13
+ def initialize(filter_factory: default_filter_factory, attribute_map_class: default_attribute_map_class)
11
14
  @filter_factory = filter_factory
12
15
  @attribute_map_class = attribute_map_class
13
16
  end
@@ -29,6 +32,22 @@ module PackAPI::Mapping
29
32
 
30
33
  private
31
34
 
35
+ def default_filter_factory
36
+ conventional_constant('Filters', resource_name, 'FilterFactory').constantize.new
37
+ end
38
+
39
+ def default_attribute_map_class
40
+ conventional_constant("#{resource_name}AttributeMap").safe_constantize
41
+ end
42
+
43
+ def conventional_constant(*segments)
44
+ [self.class.name.deconstantize.presence, *segments].compact.join('::')
45
+ end
46
+
47
+ def resource_name
48
+ self.class.name.demodulize.delete_suffix('FilterMap')
49
+ end
50
+
32
51
  def attribute_filter_definition(filter_name, filter_class)
33
52
  api_filter_name = api_attribute_filter_name_map[filter_name]
34
53
  filter_class.definition.merge(name: api_filter_name)
@@ -3,16 +3,21 @@
3
3
  module PackAPI::Mapping
4
4
  class ValueObjectFactory
5
5
  class << self
6
- attr_reader :attribute_map_registry, :value_object_attributes
6
+ # defaults to "<Namespace>::AttributeMapRegistry" for a factory named "<Namespace>::ValueObjectFactory"
7
+ def attribute_map_registry
8
+ @attribute_map_registry ||= "#{name.deconstantize}::AttributeMapRegistry".safe_constantize
9
+ end
7
10
 
8
11
  def set_attribute_map_registry(registry)
9
12
  @attribute_map_registry = registry
13
+ end
14
+
15
+ def value_object_attributes
10
16
  @value_object_attributes ||= {}
11
17
  end
12
18
 
13
19
  def model_attributes_containing_value_objects(*attributes, model_class:)
14
- @value_object_attributes ||= {}
15
- @value_object_attributes[model_class] = attributes
20
+ value_object_attributes[model_class] = attributes
16
21
  end
17
22
  end
18
23
 
@@ -4,8 +4,12 @@ require 'brotli'
4
4
 
5
5
  module PackAPI::Pagination
6
6
  class OpaqueTokenV2
7
+ # brotli's default quality (11) is tuned for large static assets; cursor payloads are a few hundred bytes of
8
+ # JSON, where quality 5 yields the same size for a fraction of the CPU. Inflate does not depend on quality.
9
+ QUALITY = 5
10
+
7
11
  def self.create(unencoded)
8
- Base64.strict_encode64(Brotli.deflate(unencoded.to_json))
12
+ Base64.strict_encode64(Brotli.deflate(unencoded.to_json, quality: QUALITY))
9
13
  end
10
14
 
11
15
  def self.parse(encoded)
@@ -8,8 +8,13 @@ module PackAPI::Querying
8
8
  @filter_classes = Hash.new { |_hash, key| raise NotImplementedError, "Unsupported filter #{key}" }
9
9
  end
10
10
 
11
- def register_filter(name:, klass:)
12
- filter_classes[name] = klass
11
+ def register_filter(filter_class = nil, name: nil, klass: filter_class)
12
+ filter_classes[name || klass.filter_name] = klass
13
+ end
14
+
15
+ # registers an AttributeFilter for each filterable attribute of the attribute map's api type
16
+ def register_attribute_filters(attribute_map_class)
17
+ AttributeFilterFactory.new(attribute_map_class).from_api_type { register_filter(it) }
13
18
  end
14
19
 
15
20
  def create_filters(filter_hash)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PackAPI
4
- VERSION = "1.0.17"
4
+ VERSION = "1.1.0"
5
5
  end
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.17
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Flytedesk
@@ -269,7 +269,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
269
269
  - !ruby/object:Gem::Version
270
270
  version: '0'
271
271
  requirements: []
272
- rubygems_version: 4.0.6
272
+ rubygems_version: 4.0.16
273
273
  specification_version: 4
274
274
  summary: Building blocks for implementing APIs around domain models
275
275
  test_files: []