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 +4 -4
- data/README.md +65 -9
- data/lib/pack_api/mapping/attribute_map.rb +14 -4
- data/lib/pack_api/mapping/attribute_map_registry.rb +18 -5
- data/lib/pack_api/mapping/error_hash_to_api_attributes_transformer.rb +8 -2
- data/lib/pack_api/mapping/filter_map.rb +20 -1
- data/lib/pack_api/mapping/value_object_factory.rb +8 -3
- data/lib/pack_api/pagination/opaque_token_v2.rb +5 -1
- data/lib/pack_api/querying/filter_factory.rb +7 -2
- data/lib/pack_api/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 62245994a5ffa0d342e05ee798356a0c6d61063a513accacd43195ab74e39416
|
|
4
|
+
data.tar.gz: 8be813a9fc9f0dcbdec802796cc632c680ae1273d104acc46f1c401da21ede88
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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` -
|
|
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
|
-
|
|
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
|
-
#
|
|
162
|
-
|
|
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
|
|
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
|
-
|
|
11
|
+
def attribute_maps
|
|
12
|
+
@attribute_maps ||= {}
|
|
13
|
+
end
|
|
8
14
|
|
|
9
15
|
def register_attribute_map(attribute_map_class)
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
data/lib/pack_api/version.rb
CHANGED
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
|
|
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.
|
|
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: []
|