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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 97d36561d79762a67e842fc092ff22d1d66de8c230e1abcb6adff3bedc86edf7
4
+ data.tar.gz: e4145510ed3cb11d94340c8b48d21dd454037dd8906ac00004f5bbbdc0b43d55
5
+ SHA512:
6
+ metadata.gz: e0f38e85af2248f3898ebbf99fa534133fbc5c2e10c6e8958aa7919b75174150a1ad005f4a31ed267c1674d56f02c1ad0e58c484db523f83e486c695a38e0207
7
+ data.tar.gz: 1c5ab5ad08af202d504550d3266c539a65836f210e434caa9330dee911b2285f6fabb2b9d4507ff3bbcab33976f67f5b8ecd226c3f31f115222acac64aba0435
data/CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2025-11-10
11
+
12
+ ### Added
13
+
14
+ - Initial release of PackAPI gem
15
+ - Mapping module for transforming data between domain models and API representations
16
+ - AttributeMap for defining bidirectional mappings
17
+ - AttributeMapRegistry for centralized mapping management
18
+ - ModelToAPIAttributesTransformer and APIToModelAttributesTransformer
19
+ - ValueObjectFactory for creating value objects
20
+ - Querying module for building flexible query interfaces
21
+ - ComposableQuery and CollectionQuery
22
+ - Filter implementations (boolean, enum, numeric, range)
23
+ - FilterFactory for dynamic filter creation
24
+ - SortHash for handling sorting parameters
25
+ - Pagination module with multiple strategies
26
+ - Paginator for standard pagination
27
+ - SnapshotPaginator for consistent results
28
+ - PaginatorBuilder for custom configurations
29
+ - OpaqueTokenV2 for secure cursor tokens
30
+ - Types module with dry-types integration
31
+ - BaseType and AggregateType
32
+ - Result and CollectionResultMetadata
33
+ - Filter definition types
34
+ - Batch operation utilities
35
+ - ValuesInBatches for synchronous batch processing
36
+ - ValuesInBackgroundBatches for asynchronous batch processing
37
+
38
+ [Unreleased]: https://github.com/flytedesk/pack_api/compare/v0.1.0...HEAD
39
+ [0.1.0]: https://github.com/flytedesk/pack_api/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Flytedesk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,238 @@
1
+ # PackAPI
2
+
3
+ Building blocks for implementing APIs around domain models.
4
+
5
+ ## Overview
6
+
7
+ PackAPI provides a comprehensive set of tools for building robust API layers on top of domain models. It includes utilities for:
8
+
9
+ - **Data transformation** - Elements for passing data out of the API
10
+ - **Filter definitions** - Elements for describing the filters supported by query endpoints in the API
11
+ - **Attribute mapping** - Elements for building the mapping between domain models and API models
12
+ - **Query building** - Elements for building query endpoints based on user inputs (sort, filter, pagination)
13
+ - **Batch operations** - Elements for retrieving multiple pages of data from other query endpoints
14
+
15
+ ## Installation
16
+
17
+ Add this line to your application's Gemfile:
18
+
19
+ ```ruby
20
+ gem 'pack_api'
21
+ ```
22
+
23
+ And then execute:
24
+
25
+ ```bash
26
+ bundle install
27
+ ```
28
+
29
+ Or install it yourself as:
30
+
31
+ ```bash
32
+ gem install pack_api
33
+ ```
34
+
35
+ ## Requirements
36
+
37
+ - Ruby >= 3.0.0
38
+ - ActiveRecord >= 7.0
39
+ - dry-types ~> 1.8
40
+
41
+ ## Features
42
+
43
+ ### Mapping
44
+
45
+ The mapping module provides tools for transforming data between domain models and API representations:
46
+
47
+ - `AttributeMap` - Define bidirectional mappings between model and API attributes
48
+ - `AttributeMapRegistry` - Centralized registry for attribute mappings
49
+ - `ModelToAPIAttributesTransformer` - Transform model attributes to API format
50
+ - `APIToModelAttributesTransformer` - Transform API attributes to model format
51
+ - `ValueObjectFactory` - Create value objects from raw data
52
+
53
+ ### Querying
54
+
55
+ Build flexible query interfaces with support for filtering, sorting, and pagination:
56
+
57
+ - `ComposableQuery` - Build complex queries from simpler components
58
+ - `CollectionQuery` - Query collections with filtering and sorting
59
+ - `AbstractFilter` - Base class for custom filters
60
+ - Filter implementations for boolean, enum, numeric, and range filters
61
+ - `FilterFactory` - Create filters dynamically based on query method arguments
62
+ - `SortHash` - Handle sorting parameters
63
+
64
+ ### Pagination
65
+
66
+ Enable paginated access to resources across the API:
67
+
68
+ - `Paginator` - Standard pagination implementation
69
+ - `PaginatorBuilder` - Build paginators with custom configurations
70
+ - `SnapshotPaginator` - Enable record iteration (one by one) across results in a page,
71
+ even when the underlying records change state (and may no longer be at the same position in the result set)
72
+
73
+ ### Types
74
+
75
+ Type definitions and validation using dry-types:
76
+
77
+ - `BaseType` - Base type for API models
78
+ - `CollectionResultMetadata` - Metadata for paginated collections
79
+ - `Result` - Generic result type
80
+ - `AggregateType` - Composite types made of attributes from other types
81
+ - Filter definition types for various data types
82
+
83
+ ### Batch Operations
84
+
85
+ Utilities for processing large datasets efficiently:
86
+
87
+ - `ValuesInBatches` - Process values in batches
88
+ - `ValuesInBackgroundBatches` - Process values in background batches
89
+
90
+ ## Usage
91
+
92
+ ### Basic Example
93
+
94
+ See the test files for more detailed examples, but here's a simple usage example.
95
+
96
+ Let's assume your system has Author, Comment and BlogPost ActiveRecord models.
97
+
98
+ 1. Define value objects to contain the data passed out of the API:
99
+
100
+ ```ruby
101
+
102
+ # public/author_type.rb
103
+ class AuthorType < PackAPI::Types::BaseType
104
+ attribute :id, ::Types::String
105
+ attribute :name, ::Types::String
106
+ end
107
+
108
+ # public/comment_type.rb
109
+ class CommentType < PackAPI::Types::BaseType
110
+ attribute :text, ::Types::String
111
+ end
112
+
113
+ # public/blog_post_type.rb
114
+ class BlogPostType < PackAPI::Types::BaseType
115
+ attribute :id, ::Types::String
116
+ attribute :legacy_id, ::Types::String
117
+ attribute :title, ::Types::String
118
+ attribute :persisted, ::Types::Bool
119
+ attribute :contents, ::Types::String.optional
120
+ optional_attribute :associated, AuthorType
121
+ optional_attribute :notes, ::Types::Array.of(CommentType)
122
+ optional_attribute :earnings_float, ::Types::Coercible::Float
123
+ end
124
+ ```
125
+
126
+ 2. Define the rules for mapping between the domain models and the API value objects:
127
+
128
+ ```ruby
129
+ # api/author_attribute_map.rb
130
+ class AuthorAttributeMap < PackAPI::Mapping::AttributeMap
131
+ api_type AuthorType
132
+ model_type Author
133
+ map :name, to: :name
134
+ map :id, to: :external_id
135
+ map :blog_posts
136
+ end
137
+
138
+ # api/comment_attribute_map.rb
139
+ class CommentAttributeMap < PackAPI::Mapping::AttributeMap
140
+ api_type CommentType
141
+ model_type Comment
142
+ map :text, to: :txt
143
+ end
144
+
145
+ # api/blog_post_attribute_map.rb
146
+ class BlogPostAttributeMap < PackAPI::Mapping::AttributeMap
147
+ api_type BlogPostType
148
+ model_type BlogPost
149
+
150
+ # example API attribute mapped to a model attribute of the same name
151
+ map :title
152
+
153
+ map :contents, from_model_attribute: ->(attachment) { attachment&.blob }
154
+
155
+ # example API attribute mapped to a model attribute of a different name
156
+ map :id, to: :external_id
157
+
158
+ # example of API attribute ending in "_id"
159
+ map :legacy_id
160
+
161
+ # example of API attribute mapped to a model method (unidirectional)
162
+ map :persisted, to: :persisted?, readonly: true
163
+
164
+ # example of API association mapped to a model association
165
+ # (the association_id can also be passed in, and reported on during error cases)
166
+ map :associated, to: :author,
167
+ from_api_attribute: ->(author_id) { Author.find_by(external_id: author_id) }
168
+
169
+ map :notes, to: :comments, transform_nested_attributes_with: CommentAttributeMap
170
+
171
+ # example of OPTIONAL API attribute (association) mapped to a model method (bidirectional)
172
+ map :earnings_float, to: :earnings_float
173
+ end
174
+
175
+ ```
176
+
177
+ 3. Implement a query endpoint using the attribute map:
178
+
179
+ ```ruby
180
+ def query_blog_posts(cursor = nil, search = nil, sort = nil, page_size = 50, filters = {}, optional_attributes = [])
181
+ collection = BlogPost.all
182
+
183
+ # avoid N+1 queries for optional attributes that are associations
184
+ if optional_attributes.include?(:associated)
185
+ collection = collection.includes(:author)
186
+ end
187
+
188
+ # convert the search terms to something used by the CollectionQuery to perform searches (hash of model attributes to search terms)
189
+ if search.present?
190
+ # search through blog post title and comments
191
+ collection = collection.includes(:comments)
192
+ model_search = {
193
+ 'title' => search,
194
+ "#{Comment.table_name}.txt" => search,
195
+ }
196
+ end
197
+
198
+ # convert the API sort to model sort
199
+ model_sort = BlogPostAttributeMap.model_attribute_keys(PackAPI::Querying::SortHash.new(sort))
200
+
201
+ # convert the API filters to model filters
202
+ model_filters = BlogPostFilterMap.new.from_api_filters(filters)
203
+
204
+ # build and execute the query
205
+ query = PackAPI::Querying::CollectionQuery.new(collection:)
206
+ query.filter_factory = Filters::BlogPost::FilterFactory.new
207
+ query.call(cursor:, per_page: page_size, sort: model_sort, search: model_search, filters: model_filters)
208
+
209
+ # build and return the result
210
+ PackAPI::Types::Result.from_collection(models: query.results,
211
+ value_object_factory: ValueObjectFactory.new,
212
+ optional_attributes:,
213
+ sort: BlogPostAttributeMap.api_attribute_keys(query.sort),
214
+ paginator: query.paginator)
215
+ end
216
+ ```
217
+
218
+ ## Development
219
+
220
+ After checking out the repo, run:
221
+
222
+ ```bash
223
+ bundle install
224
+ ```
225
+
226
+ Run the test suite:
227
+
228
+ ```bash
229
+ bundle exec rspec
230
+ ```
231
+
232
+ ## Contributing
233
+
234
+ Bug reports and pull requests are welcome on GitHub at https://github.com/flytedesk/pack_api.
235
+
236
+ ## License
237
+
238
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1 @@
1
+ require_relative '../../types'
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI
4
+ ###
5
+ # Error class for passing errors from the model to the API logic
6
+ class InternalError < StandardError
7
+ attr_reader :object, :options
8
+
9
+ def initialize(msg = nil, object: nil, options: {})
10
+ super(msg)
11
+ @object = object
12
+ @options = options
13
+ end
14
+
15
+ def message
16
+ to_s
17
+ end
18
+
19
+ def to_s
20
+ return super unless object.present?
21
+
22
+ "#{super} - #{object.inspect}"
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Mapping
4
+ class AbstractTransformer
5
+ attr_accessor :mappings, :api_type, :model_type, :data_source
6
+ attr_reader :options
7
+
8
+ def initialize(config)
9
+ @mappings = config[:mappings]
10
+ @api_type = config[:api_type]
11
+ @model_type = config[:model_type]
12
+ @transform_value = config[:transform_value]
13
+ @options = {}
14
+ end
15
+
16
+ ###
17
+ # @abstract
18
+ def execute
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def options=(value)
23
+ @options = value.presence || {}
24
+ end
25
+
26
+ protected
27
+
28
+ def transform_value(api_attribute, value)
29
+ @transform_value.call(api_attribute, value)
30
+ end
31
+
32
+ def model_attribute(api_attribute)
33
+ return api_attribute if api_attribute.start_with?('_')
34
+
35
+ unless mappings.key?(api_attribute)
36
+ raise ActiveModel::UnknownAttributeError.new(@model_type.name, api_attribute)
37
+ end
38
+
39
+ mappings[api_attribute]
40
+ end
41
+
42
+ def api_attribute_names
43
+ api_type.attribute_names
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Mapping
4
+ ###
5
+ # Specialized attribute transformer allowing API attributes be converted to the attribute names needed to
6
+ # creating/updating an ActiveRecord model.
7
+ class APIToModelAttributesTransformer < AbstractTransformer
8
+
9
+ def execute
10
+ result = {}
11
+ attribute_names = NormalizedAPIAttribute.new(api_attribute_names)
12
+ data_source.each do |api_attribute, api_value|
13
+ normalized_api_attribute = attribute_names.normalize(api_attribute)
14
+ 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 })
17
+ end
18
+ result
19
+ end
20
+
21
+ private
22
+
23
+ def model_value(api_attribute, api_value)
24
+ transform_value(api_attribute, api_value)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackAPI::Mapping
4
+ ###
5
+ # Specialized attribute transformer converting attribute Hashes
6
+ #
7
+ # Does not work with models (only Hashes)
8
+ # Does not convert values
9
+ # Converts model attributes to API attributes
10
+ # Converts API attributes to model attributes
11
+ class AttributeHashTransformer < AbstractTransformer
12
+
13
+ def execute
14
+ options.fetch(:contains_model_attributes, true) ?
15
+ model_attributes_to_api_attributes :
16
+ api_attributes_to_model_attributes
17
+ end
18
+
19
+ protected
20
+
21
+ def api_attributes_to_model_attributes
22
+ attribute_names = NormalizedAPIAttribute.new(api_attribute_names)
23
+ result = {}
24
+ data_source.each_key do |api_attribute|
25
+ normalized_api_attribute = attribute_names.normalize(api_attribute)
26
+ next unless mappings.key?(normalized_api_attribute)
27
+
28
+ model_attribute = model_attribute(normalized_api_attribute)
29
+ result[model_attribute] = data_source[api_attribute]
30
+ end
31
+ result
32
+ end
33
+
34
+ def model_attributes_to_api_attributes
35
+ reversed_mappings = mappings.invert
36
+ result = {}
37
+ data_source.each_key do |model_attribute|
38
+ next unless reversed_mappings.key?(model_attribute)
39
+
40
+ api_attribute = reversed_mappings[model_attribute]
41
+ result[api_attribute] = data_source[model_attribute]
42
+ end
43
+ result
44
+ end
45
+ end
46
+ end