reactor_sdk 0.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.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +19 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +281 -0
  5. data/lib/reactor_sdk/authentication.rb +137 -0
  6. data/lib/reactor_sdk/client.rb +186 -0
  7. data/lib/reactor_sdk/configuration.rb +102 -0
  8. data/lib/reactor_sdk/connection.rb +342 -0
  9. data/lib/reactor_sdk/endpoints/app_configurations.rb +42 -0
  10. data/lib/reactor_sdk/endpoints/audit_events.rb +64 -0
  11. data/lib/reactor_sdk/endpoints/base_endpoint.rb +207 -0
  12. data/lib/reactor_sdk/endpoints/builds.rb +62 -0
  13. data/lib/reactor_sdk/endpoints/callbacks.rb +38 -0
  14. data/lib/reactor_sdk/endpoints/companies.rb +42 -0
  15. data/lib/reactor_sdk/endpoints/data_elements.rb +251 -0
  16. data/lib/reactor_sdk/endpoints/environments.rb +174 -0
  17. data/lib/reactor_sdk/endpoints/extension_package_usage_authorizations.rb +51 -0
  18. data/lib/reactor_sdk/endpoints/extension_packages.rb +63 -0
  19. data/lib/reactor_sdk/endpoints/extensions.rb +181 -0
  20. data/lib/reactor_sdk/endpoints/hosts.rb +101 -0
  21. data/lib/reactor_sdk/endpoints/libraries.rb +872 -0
  22. data/lib/reactor_sdk/endpoints/notes.rb +11 -0
  23. data/lib/reactor_sdk/endpoints/profiles.rb +14 -0
  24. data/lib/reactor_sdk/endpoints/properties.rb +123 -0
  25. data/lib/reactor_sdk/endpoints/revisions.rb +102 -0
  26. data/lib/reactor_sdk/endpoints/rule_components.rb +218 -0
  27. data/lib/reactor_sdk/endpoints/rules.rb +240 -0
  28. data/lib/reactor_sdk/endpoints/search.rb +23 -0
  29. data/lib/reactor_sdk/endpoints/secrets.rb +76 -0
  30. data/lib/reactor_sdk/error.rb +115 -0
  31. data/lib/reactor_sdk/library_comparison_builder.rb +74 -0
  32. data/lib/reactor_sdk/library_snapshot_builder.rb +66 -0
  33. data/lib/reactor_sdk/paginator.rb +92 -0
  34. data/lib/reactor_sdk/rate_limiter.rb +96 -0
  35. data/lib/reactor_sdk/reference_extractor.rb +34 -0
  36. data/lib/reactor_sdk/resource_metadata.rb +73 -0
  37. data/lib/reactor_sdk/resource_normalizer.rb +90 -0
  38. data/lib/reactor_sdk/resources/app_configuration.rb +20 -0
  39. data/lib/reactor_sdk/resources/audit_event.rb +45 -0
  40. data/lib/reactor_sdk/resources/base_resource.rb +181 -0
  41. data/lib/reactor_sdk/resources/build.rb +64 -0
  42. data/lib/reactor_sdk/resources/callback.rb +16 -0
  43. data/lib/reactor_sdk/resources/company.rb +38 -0
  44. data/lib/reactor_sdk/resources/comprehensive_data_element.rb +28 -0
  45. data/lib/reactor_sdk/resources/comprehensive_extension.rb +30 -0
  46. data/lib/reactor_sdk/resources/comprehensive_resource.rb +31 -0
  47. data/lib/reactor_sdk/resources/comprehensive_rule.rb +26 -0
  48. data/lib/reactor_sdk/resources/comprehensive_upstream_chain.rb +50 -0
  49. data/lib/reactor_sdk/resources/comprehensive_upstream_chain_entry.rb +34 -0
  50. data/lib/reactor_sdk/resources/data_element.rb +108 -0
  51. data/lib/reactor_sdk/resources/environment.rb +45 -0
  52. data/lib/reactor_sdk/resources/extension.rb +66 -0
  53. data/lib/reactor_sdk/resources/extension_package.rb +49 -0
  54. data/lib/reactor_sdk/resources/extension_package_usage_authorization.rb +26 -0
  55. data/lib/reactor_sdk/resources/host.rb +68 -0
  56. data/lib/reactor_sdk/resources/library.rb +67 -0
  57. data/lib/reactor_sdk/resources/library_comparison.rb +72 -0
  58. data/lib/reactor_sdk/resources/library_comparison_entry.rb +144 -0
  59. data/lib/reactor_sdk/resources/library_snapshot.rb +118 -0
  60. data/lib/reactor_sdk/resources/library_snapshot_extension_index.rb +70 -0
  61. data/lib/reactor_sdk/resources/library_snapshot_index.rb +169 -0
  62. data/lib/reactor_sdk/resources/library_with_resources.rb +194 -0
  63. data/lib/reactor_sdk/resources/note.rb +37 -0
  64. data/lib/reactor_sdk/resources/profile.rb +22 -0
  65. data/lib/reactor_sdk/resources/property.rb +44 -0
  66. data/lib/reactor_sdk/resources/revision.rb +156 -0
  67. data/lib/reactor_sdk/resources/rule.rb +44 -0
  68. data/lib/reactor_sdk/resources/rule_component.rb +101 -0
  69. data/lib/reactor_sdk/resources/search_results.rb +28 -0
  70. data/lib/reactor_sdk/resources/secret.rb +17 -0
  71. data/lib/reactor_sdk/resources/upstream_chain.rb +80 -0
  72. data/lib/reactor_sdk/resources/upstream_chain_entry.rb +55 -0
  73. data/lib/reactor_sdk/response_parser.rb +160 -0
  74. data/lib/reactor_sdk/version.rb +5 -0
  75. data/lib/reactor_sdk.rb +79 -0
  76. data/reactor_sdk.gemspec +70 -0
  77. data/sig/reactor_sdk.rbs +346 -0
  78. metadata +293 -0
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # @file endpoints/rules.rb
5
+ # @description Endpoint group for Adobe Launch Rule resources.
6
+ #
7
+ # Rules define the logic Adobe Launch executes. They consist of
8
+ # conditions (when to run) and actions (what to do). Rules belong
9
+ # to a property and are versioned — the revisions endpoint provides
10
+ # point-in-time snapshots used by the diff engine.
11
+ #
12
+ # Important: rules must be revised before they can be added to a library.
13
+ # Call revise(rule_id) after creating or updating a rule.
14
+ #
15
+ # @domain Endpoints
16
+ # @see https://developer.adobe.com/experience-platform/documentation/tags/api/endpoints/rules/
17
+ #
18
+
19
+ module ReactorSDK
20
+ module Endpoints
21
+ class Rules < BaseEndpoint
22
+ ##
23
+ # Lists all rules for a given property.
24
+ # Follows pagination automatically — returns all rules.
25
+ #
26
+ # @param property_id [String] Adobe property ID
27
+ # @return [Array<ReactorSDK::Resources::Rule>]
28
+ # @raise [ReactorSDK::ResourceNotFoundError] if the property does not exist
29
+ #
30
+ def list_for_property(property_id)
31
+ list_resources("/properties/#{property_id}/rules", Resources::Rule)
32
+ end
33
+
34
+ ##
35
+ # Retrieves a single rule by its Adobe ID.
36
+ #
37
+ # @param rule_id [String] Adobe rule ID (format: "RL" + hex string)
38
+ # @return [ReactorSDK::Resources::Rule]
39
+ # @raise [ReactorSDK::ResourceNotFoundError] if the rule does not exist
40
+ #
41
+ def find(rule_id)
42
+ fetch_resource("/rules/#{rule_id}", Resources::Rule)
43
+ end
44
+
45
+ ##
46
+ # Retrieves the property that owns a rule.
47
+ #
48
+ # @param rule_id [String] Adobe rule ID
49
+ # @return [ReactorSDK::Resources::Property]
50
+ #
51
+ def property(rule_id)
52
+ fetch_resource("/rules/#{rule_id}/property", Resources::Property)
53
+ end
54
+
55
+ ##
56
+ # Lists the libraries containing a rule.
57
+ #
58
+ # @param rule_id [String] Adobe rule ID
59
+ # @return [Array<ReactorSDK::Resources::Library>]
60
+ #
61
+ def libraries(rule_id)
62
+ list_resources("/rules/#{rule_id}/libraries", Resources::Library)
63
+ end
64
+
65
+ ##
66
+ # Resolves the rule across the ordered upstream library chain.
67
+ #
68
+ # This is a convenience wrapper around Libraries#upstream_chain_for_resource
69
+ # so rule workflows can stay resource-centric at the call site.
70
+ #
71
+ # @param rule_or_id [String, ReactorSDK::Resources::Rule]
72
+ # @param library_id [String] Adobe library ID used as the comparison root
73
+ # @param property_id [String] Adobe property ID containing the library chain
74
+ # @return [ReactorSDK::Resources::UpstreamChain]
75
+ #
76
+ def upstream_chain(rule_or_id, library_id:, property_id:)
77
+ libraries_endpoint.upstream_chain_for_resource(
78
+ rule_or_id,
79
+ library_id: library_id,
80
+ property_id: property_id,
81
+ resource_type: 'rules'
82
+ )
83
+ end
84
+
85
+ ##
86
+ # Fetches the rule from a library-context review snapshot together with
87
+ # its associated rule components and normalized review payload.
88
+ #
89
+ # @param rule_id [String]
90
+ # @param library_id [String]
91
+ # @param property_id [String]
92
+ # @return [ReactorSDK::Resources::ComprehensiveRule]
93
+ #
94
+ def find_comprehensive(rule_id, library_id:, property_id:)
95
+ snapshot = libraries_endpoint.find_snapshot(library_id, property_id: property_id)
96
+ comprehensive = snapshot.comprehensive_resource(rule_id, resource_type: 'rules')
97
+ unless comprehensive
98
+ raise ReactorSDK::ResourceNotFoundError,
99
+ "Rule #{rule_id} was not found in library #{library_id}"
100
+ end
101
+
102
+ comprehensive
103
+ end
104
+
105
+ ##
106
+ # Resolves the rule across the ordered upstream chain and returns
107
+ # comprehensive review objects for the target and upstream entries.
108
+ #
109
+ # @param rule_or_id [String, ReactorSDK::Resources::Rule]
110
+ # @param library_id [String]
111
+ # @param property_id [String]
112
+ # @return [ReactorSDK::Resources::ComprehensiveUpstreamChain]
113
+ #
114
+ def comprehensive_upstream_chain(rule_or_id, library_id:, property_id:)
115
+ libraries_endpoint.comprehensive_upstream_chain_for_resource(
116
+ rule_or_id,
117
+ library_id: library_id,
118
+ property_id: property_id,
119
+ resource_type: 'rules'
120
+ )
121
+ end
122
+
123
+ ##
124
+ # Retrieves the origin revision head for a rule.
125
+ #
126
+ # @param rule_id [String] Adobe rule ID
127
+ # @return [ReactorSDK::Resources::Rule]
128
+ #
129
+ def origin(rule_id)
130
+ fetch_resource("/rules/#{rule_id}/origin", Resources::Rule)
131
+ end
132
+
133
+ ##
134
+ # Creates a new rule within a property.
135
+ #
136
+ # @param property_id [String] Adobe property ID
137
+ # @param name [String] Display name for the rule
138
+ # @param enabled [Boolean] Whether the rule is enabled (default: true)
139
+ # @return [ReactorSDK::Resources::Rule] The newly created rule
140
+ # @raise [ReactorSDK::UnprocessableEntityError] if attributes are invalid
141
+ #
142
+ def create(property_id:, name:, enabled: true)
143
+ create_resource(
144
+ "/properties/#{property_id}/rules",
145
+ 'rules',
146
+ Resources::Rule,
147
+ attributes: { name: name, enabled: enabled }
148
+ )
149
+ end
150
+
151
+ ##
152
+ # Updates an existing rule.
153
+ #
154
+ # @param rule_id [String] Adobe rule ID
155
+ # @param attributes [Hash] Fields to update (e.g. { name: "New Name" })
156
+ # @return [ReactorSDK::Resources::Rule] The updated rule
157
+ # @raise [ReactorSDK::ResourceNotFoundError] if the rule does not exist
158
+ #
159
+ def update(rule_id, attributes)
160
+ update_resource("/rules/#{rule_id}", rule_id, 'rules', Resources::Rule, attributes: attributes)
161
+ end
162
+
163
+ ##
164
+ # Revises a rule so it can be added to a library.
165
+ #
166
+ # Adobe Launch requires every resource to be explicitly revised before
167
+ # it can be added to a library. A newly created or updated rule cannot
168
+ # be added to a library until revised.
169
+ #
170
+ # Always call revise after create or update, before libraries.add_rules.
171
+ #
172
+ # @param rule_id [String] Adobe rule ID
173
+ # @return [ReactorSDK::Resources::Rule] The revised rule
174
+ # @raise [ReactorSDK::ResourceNotFoundError] if the rule does not exist
175
+ #
176
+ def revise(rule_id)
177
+ update_resource(
178
+ "/rules/#{rule_id}",
179
+ rule_id,
180
+ 'rules',
181
+ Resources::Rule,
182
+ attributes: {},
183
+ meta: { action: 'revise' }
184
+ )
185
+ end
186
+
187
+ ##
188
+ # Deletes a rule permanently.
189
+ #
190
+ # @param rule_id [String] Adobe rule ID
191
+ # @return [nil]
192
+ # @raise [ReactorSDK::ResourceNotFoundError] if the rule does not exist
193
+ #
194
+ def delete(rule_id)
195
+ delete_resource("/rules/#{rule_id}")
196
+ end
197
+
198
+ ##
199
+ # Creates a note on a rule.
200
+ #
201
+ # @param rule_id [String] Adobe rule ID
202
+ # @param text [String] Note body text
203
+ # @return [ReactorSDK::Resources::Note]
204
+ #
205
+ def create_note(rule_id, text)
206
+ create_note_for_path("/rules/#{rule_id}/notes", text)
207
+ end
208
+
209
+ ##
210
+ # Lists notes attached directly to a rule.
211
+ #
212
+ # @param rule_id [String]
213
+ # @return [Array<ReactorSDK::Resources::Note>]
214
+ #
215
+ def list_notes(rule_id)
216
+ list_notes_for_path("/rules/#{rule_id}/notes")
217
+ end
218
+
219
+ ##
220
+ # Lists rule components associated with the rule's component notes route.
221
+ #
222
+ # @param rule_id [String]
223
+ # @return [Array<ReactorSDK::Resources::RuleComponent>]
224
+ #
225
+ def rule_component_notes(rule_id)
226
+ list_resources("/rules/#{rule_id}/rule_component_notes", Resources::RuleComponent)
227
+ end
228
+
229
+ private
230
+
231
+ def libraries_endpoint
232
+ @libraries_endpoint ||= Endpoints::Libraries.new(
233
+ connection: @connection,
234
+ paginator: @paginator,
235
+ parser: @parser
236
+ )
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactorSDK
4
+ module Endpoints
5
+ class Search < BaseEndpoint
6
+ def perform(query:, from: nil, size: nil, sort: nil, resource_types: nil)
7
+ payload = { query: query }
8
+ payload[:from] = from unless from.nil?
9
+ payload[:size] = size unless size.nil?
10
+ payload[:sort] = sort unless sort.nil?
11
+ payload[:resource_types] = resource_types unless resource_types.nil?
12
+
13
+ response = @connection.post('/search', payload)
14
+ Resources::SearchResults.new(
15
+ results: @parser.parse_many_auto(response['data']),
16
+ meta: response.fetch('meta', {})
17
+ )
18
+ end
19
+
20
+ alias query perform
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactorSDK
4
+ module Endpoints
5
+ class Secrets < BaseEndpoint
6
+ def list_for_property(property_id)
7
+ list_resources("/properties/#{property_id}/secrets", Resources::Secret)
8
+ end
9
+
10
+ def list_for_environment(environment_id)
11
+ list_resources("/environments/#{environment_id}/secrets", Resources::Secret)
12
+ end
13
+
14
+ def find(secret_id)
15
+ fetch_resource("/secrets/#{secret_id}", Resources::Secret)
16
+ end
17
+
18
+ def create(property_id:, environment_id:, attributes:)
19
+ create_resource(
20
+ "/properties/#{property_id}/secrets",
21
+ 'secrets',
22
+ Resources::Secret,
23
+ attributes: attributes,
24
+ relationships: {
25
+ environment: {
26
+ data: { id: environment_id, type: 'environments' }
27
+ }
28
+ }
29
+ )
30
+ end
31
+
32
+ def test_or_retry(secret_id, type_of:, action:)
33
+ update_resource(
34
+ "/secrets/#{secret_id}",
35
+ secret_id,
36
+ 'secrets',
37
+ Resources::Secret,
38
+ attributes: { type_of: type_of },
39
+ meta: { action: action }
40
+ )
41
+ end
42
+
43
+ def test(secret_id, type_of:)
44
+ test_or_retry(secret_id, type_of: type_of, action: 'test')
45
+ end
46
+
47
+ def retry(secret_id, type_of:)
48
+ test_or_retry(secret_id, type_of: type_of, action: 'retry')
49
+ end
50
+
51
+ def delete(secret_id)
52
+ delete_resource("/secrets/#{secret_id}")
53
+ end
54
+
55
+ def data_elements(secret_id)
56
+ list_resources("/secrets/#{secret_id}/data_elements", Resources::DataElement)
57
+ end
58
+
59
+ def environment(secret_id)
60
+ fetch_resource("/secrets/#{secret_id}/environment", Resources::Environment)
61
+ end
62
+
63
+ def property(secret_id)
64
+ fetch_resource("/secrets/#{secret_id}/property", Resources::Property)
65
+ end
66
+
67
+ def list_notes(secret_id)
68
+ list_notes_for_path("/secrets/#{secret_id}/notes")
69
+ end
70
+
71
+ def create_note(secret_id, text)
72
+ create_note_for_path("/secrets/#{secret_id}/notes", text)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # @file error.rb
5
+ # @description Typed error hierarchy for the ReactorSDK gem.
6
+ #
7
+ # All errors inherit from ReactorSDK::Error so callers can rescue
8
+ # broadly or narrowly depending on their needs.
9
+ #
10
+ # Rescue broadly:
11
+ # rescue ReactorSDK::Error => e
12
+ #
13
+ # Rescue specifically:
14
+ # rescue ReactorSDK::RateLimitError => e
15
+ # sleep e.retry_after
16
+ # retry
17
+ #
18
+ # @domain Infrastructure
19
+ #
20
+
21
+ module ReactorSDK
22
+ ##
23
+ # Base class for all ReactorSDK errors.
24
+ # Every other error in this file inherits from this class.
25
+ #
26
+ class Error < StandardError
27
+ # @return [Integer, nil] HTTP status code from the API response
28
+ attr_reader :status
29
+
30
+ # @return [Exception, nil] The original exception that caused this one
31
+ attr_reader :cause
32
+
33
+ ##
34
+ # @param message [String] Human-readable description of what went wrong
35
+ # @param status [Integer, nil] HTTP status code from the API response
36
+ # @param cause [Exception, nil] Underlying exception if this wraps another error
37
+ #
38
+ def initialize(message, status: nil, cause: nil)
39
+ super(message)
40
+ @status = status
41
+ @cause = cause
42
+ end
43
+ end
44
+
45
+ ##
46
+ # Raised when Adobe IMS token fetch or refresh fails.
47
+ # Most commonly caused by an incorrect client_id or client_secret.
48
+ #
49
+ class AuthenticationError < Error; end
50
+
51
+ ##
52
+ # Raised when the token is valid but lacks permission for the resource.
53
+ # Most commonly caused by the org not having access to a property.
54
+ #
55
+ class AuthorizationError < Error; end
56
+
57
+ ##
58
+ # Raised when the requested Adobe resource does not exist (HTTP 404).
59
+ #
60
+ class ResourceNotFoundError < Error; end
61
+
62
+ ##
63
+ # Raised when a request payload fails Adobe's validation (HTTP 422).
64
+ # Check validation_errors for field-level details returned by Adobe.
65
+ #
66
+ class UnprocessableEntityError < Error
67
+ # @return [Array<Hash>] Validation error objects returned by the Adobe API
68
+ attr_reader :validation_errors
69
+
70
+ ##
71
+ # @param message [String] Error description
72
+ # @param validation_errors [Array<Hash>] Adobe API validation error objects
73
+ # @param opts [Hash] Passed through to ReactorSDK::Error
74
+ #
75
+ def initialize(message, validation_errors: [], **)
76
+ super(message, **)
77
+ @validation_errors = validation_errors
78
+ end
79
+ end
80
+
81
+ ##
82
+ # Raised when the rate limit is hit after all retries are exhausted (HTTP 429).
83
+ # The retry_after value is taken from Adobe's Retry-After response header.
84
+ #
85
+ class RateLimitError < Error
86
+ # @return [Integer, nil] Seconds to wait before the next request
87
+ attr_reader :retry_after
88
+
89
+ ##
90
+ # @param message [String] Error description
91
+ # @param retry_after [Integer, nil] Seconds until rate limit resets
92
+ # @param opts [Hash] Passed through to ReactorSDK::Error
93
+ #
94
+ def initialize(message, retry_after: nil, **)
95
+ super(message, **)
96
+ @retry_after = retry_after
97
+ end
98
+ end
99
+
100
+ ##
101
+ # Raised when Adobe returns a 5xx server error after all retries exhausted.
102
+ #
103
+ class ServerError < Error; end
104
+
105
+ ##
106
+ # Raised when an HTTP response body cannot be parsed as valid JSON.
107
+ #
108
+ class ParseError < Error; end
109
+
110
+ ##
111
+ # Raised when a required configuration value is missing or blank.
112
+ # Caught at client initialization time — never mid-request.
113
+ #
114
+ class ConfigurationError < Error; end
115
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactorSDK
4
+ class LibraryComparisonBuilder
5
+ RESOURCE_TYPE_ORDER = {
6
+ 'rules' => 0,
7
+ 'data_elements' => 1,
8
+ 'extensions' => 2
9
+ }.freeze
10
+
11
+ def initialize(snapshot_loader:)
12
+ @snapshot_loader = snapshot_loader
13
+ end
14
+
15
+ def build(current_library_id, baseline_library_id:, property_id:)
16
+ current_snapshot = @snapshot_loader.call(current_library_id, property_id: property_id)
17
+ baseline_snapshot = @snapshot_loader.call(baseline_library_id, property_id: property_id)
18
+
19
+ Resources::LibraryComparison.new(
20
+ current_library_id: current_library_id,
21
+ baseline_library_id: baseline_library_id,
22
+ property_id: property_id,
23
+ current_snapshot: current_snapshot,
24
+ baseline_snapshot: baseline_snapshot,
25
+ entries: build_entries(current_snapshot, baseline_snapshot)
26
+ )
27
+ end
28
+
29
+ private
30
+
31
+ def build_entries(current_snapshot, baseline_snapshot)
32
+ comparable_resource_ids(current_snapshot, baseline_snapshot)
33
+ .map { |resource_id| build_entry(current_snapshot, baseline_snapshot, resource_id) }
34
+ .sort_by { |entry| entry_sort_key(entry) }
35
+ end
36
+
37
+ def comparable_resource_ids(current_snapshot, baseline_snapshot)
38
+ (current_snapshot.top_level_resources.map(&:id) + baseline_snapshot.top_level_resources.map(&:id)).uniq
39
+ end
40
+
41
+ def build_entry(current_snapshot, baseline_snapshot, resource_id)
42
+ current_resource = current_snapshot.find_resource(resource_id)
43
+ baseline_resource = baseline_snapshot.find_resource(resource_id)
44
+ resource_type = current_resource&.type || baseline_resource&.type
45
+
46
+ Resources::LibraryComparisonEntry.new(
47
+ resource_id: resource_id,
48
+ resource_type: resource_type,
49
+ current_library_id: current_snapshot.library.id,
50
+ baseline_library_id: baseline_snapshot.library.id,
51
+ current_resource: current_resource,
52
+ baseline_resource: baseline_resource,
53
+ current_revision_id: current_snapshot.resource_revision_id(resource_id),
54
+ baseline_revision_id: baseline_snapshot.resource_revision_id(resource_id),
55
+ current_comprehensive_resource: comprehensive_resource_for(current_snapshot, resource_id, resource_type),
56
+ baseline_comprehensive_resource: comprehensive_resource_for(baseline_snapshot, resource_id, resource_type)
57
+ )
58
+ end
59
+
60
+ def comprehensive_resource_for(snapshot, resource_id, resource_type)
61
+ return nil if resource_type.nil?
62
+
63
+ snapshot.comprehensive_resource(resource_id, resource_type: resource_type)
64
+ end
65
+
66
+ def entry_sort_key(entry)
67
+ [
68
+ RESOURCE_TYPE_ORDER.fetch(entry.resource_type, RESOURCE_TYPE_ORDER.length),
69
+ entry.resource_name.to_s,
70
+ entry.resource_id
71
+ ]
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactorSDK
4
+ class LibrarySnapshotBuilder
5
+ def initialize(library_loader:, revisions_endpoint:, rule_components_endpoint:)
6
+ @library_loader = library_loader
7
+ @revisions_endpoint = revisions_endpoint
8
+ @rule_components_endpoint = rule_components_endpoint
9
+ end
10
+
11
+ def build(library_id, property_id:)
12
+ library = @library_loader.call(library_id)
13
+
14
+ Resources::LibrarySnapshot.new(
15
+ property_id: property_id,
16
+ library: library,
17
+ rule_components_by_rule_id: build_rule_components_index(library)
18
+ )
19
+ end
20
+
21
+ private
22
+
23
+ def build_rule_components_index(library)
24
+ Array(library.rules).to_h do |rule|
25
+ [rule.id, build_rule_components_snapshot(rule)]
26
+ end
27
+ end
28
+
29
+ def build_rule_components_snapshot(rule)
30
+ current_components = @rule_components_endpoint.list_for_rule(rule.id)
31
+ rule_component_ids = point_in_time_rule_component_ids(rule)
32
+ return current_components if rule_component_ids.empty?
33
+
34
+ current_components_by_id = current_components.to_h do |component|
35
+ [component.id, component]
36
+ end
37
+
38
+ rule_component_ids.filter_map do |component_id|
39
+ current_components_by_id[component_id] || find_rule_component(component_id)
40
+ end.uniq
41
+ end
42
+
43
+ def point_in_time_rule_component_ids(rule)
44
+ revision_id = current_revision_id_for(rule)
45
+ return [] if revision_id.nil?
46
+
47
+ revision = @revisions_endpoint.find(revision_id)
48
+ Array(revision.entity_relationships.dig('rule_components', 'data')).filter_map { |item| item['id'] }.uniq
49
+ rescue ReactorSDK::Error
50
+ []
51
+ end
52
+
53
+ def current_revision_id_for(rule)
54
+ return rule.revision_id if rule.respond_to?(:revision_id)
55
+ return rule.relationship_id('latest_revision') if rule.respond_to?(:relationship_id)
56
+
57
+ nil
58
+ end
59
+
60
+ def find_rule_component(rule_component_id)
61
+ @rule_components_endpoint.find(rule_component_id)
62
+ rescue ReactorSDK::ResourceNotFoundError
63
+ nil
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # @file paginator.rb
5
+ # @description Handles cursor-based pagination for Reactor API list endpoints.
6
+ #
7
+ # The Reactor API paginates all list responses using JSON:API cursor links.
8
+ # Each response includes a `links` object with a `next` URL. This class
9
+ # follows that cursor until it is absent, collecting all records into a
10
+ # single flat array.
11
+ #
12
+ # IMPORTANT: Always use this paginator for list endpoints.
13
+ # Never make a single GET to a list endpoint — Adobe defaults to 25 records
14
+ # per page and silently truncates results without any warning or error.
15
+ # A property with 100 rules will return only 25 without pagination.
16
+ #
17
+ # @domain Infrastructure
18
+ # @depends ReactorSDK::Connection
19
+ #
20
+ # @example Fetch all rules for a property
21
+ # paginator = ReactorSDK::Paginator.new(connection)
22
+ # records = paginator.all("/properties/PR123/rules")
23
+ # # => Array of all rule hashes across every page
24
+ #
25
+
26
+ module ReactorSDK
27
+ class Paginator
28
+ # Number of records to request per page.
29
+ # Adobe's maximum is 100 — always request the maximum to minimise
30
+ # the number of API calls made.
31
+ DEFAULT_PAGE_SIZE = 100
32
+
33
+ ##
34
+ # @param connection [ReactorSDK::Connection] Authenticated HTTP connection
35
+ #
36
+ def initialize(connection)
37
+ @connection = connection
38
+ end
39
+
40
+ ##
41
+ # Fetches every record from a paginated list endpoint.
42
+ #
43
+ # Follows the `links.next` cursor in each response until it is absent,
44
+ # then returns all collected records as a single flat array.
45
+ #
46
+ # An optional block can be provided to process each record as it arrives
47
+ # rather than waiting for all pages to complete — useful for large datasets.
48
+ #
49
+ # @param path [String] Relative API path (e.g. "/properties/PR123/rules")
50
+ # @param params [Hash] Additional query parameters to merge into the request
51
+ # @yield [Hash] Each raw JSON:API record hash as it is fetched (optional)
52
+ # @return [Array<Hash>] All records across every page as a flat array
53
+ # @raise [ReactorSDK::Error] on any non-2xx response during pagination
54
+ #
55
+ def all(path, params: {})
56
+ records = []
57
+ next_url = build_initial_url(path, params)
58
+
59
+ while next_url
60
+ response = @connection.get(next_url)
61
+ data = Array(response&.fetch('data', []))
62
+
63
+ data.each do |record|
64
+ yield record if block_given?
65
+ records << record
66
+ end
67
+
68
+ next_url = response&.dig('links', 'next')
69
+ end
70
+
71
+ records
72
+ end
73
+
74
+ private
75
+
76
+ ##
77
+ # Builds the initial request URL with the page size parameter appended.
78
+ # Merges any caller-supplied params so they are included on every page.
79
+ #
80
+ # @param path [String] Base API path without query string
81
+ # @param params [Hash] Caller-supplied query parameters
82
+ # @return [String] Full path with query string
83
+ #
84
+ def build_initial_url(path, params)
85
+ query_params = { 'page[size]' => DEFAULT_PAGE_SIZE }
86
+ .merge(params.transform_keys(&:to_s))
87
+
88
+ query_string = URI.encode_www_form(query_params)
89
+ "#{path}?#{query_string}"
90
+ end
91
+ end
92
+ end