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,872 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # @file endpoints/libraries.rb
5
+ # @description Endpoint group for Adobe Launch Library resources.
6
+ #
7
+ # Libraries collect rules, data elements, and extensions into a
8
+ # deployable bundle. They move through a state machine:
9
+ # development -> submitted -> approved -> rejected -> published.
10
+ #
11
+ # Resource management methods follow JSON:API relationship semantics:
12
+ #
13
+ # add_* POST /libraries/:id/relationships/:type
14
+ # Adds the specified resources — existing resources are kept.
15
+ # Use for incremental additions.
16
+ #
17
+ # remove_* DELETE /libraries/:id/relationships/:type
18
+ # Removes only the specified resources — others are kept.
19
+ # Use for targeted removals.
20
+ #
21
+ # set_* PATCH /libraries/:id/relationships/:type
22
+ # Replaces the ENTIRE list with exactly what you send.
23
+ # Anything not included is removed. Use with caution —
24
+ # passing an empty array removes all resources of that type.
25
+ #
26
+ # @domain Endpoints
27
+ # @see https://developer.adobe.com/experience-platform/documentation/tags/api/endpoints/libraries/
28
+ #
29
+
30
+ module ReactorSDK
31
+ module Endpoints
32
+ class Libraries < BaseEndpoint
33
+ # Adobe Launch environment stages in upstream order.
34
+ # Development is at the bottom — Production is at the top.
35
+ UPSTREAM_STAGE_ORDER = %w[development staging production].freeze
36
+
37
+ # Maps user-facing library states to the Reactor API transition actions.
38
+ TRANSITION_ACTIONS = {
39
+ 'development' => 'develop',
40
+ 'develop' => 'develop',
41
+ 'submitted' => 'submit',
42
+ 'submit' => 'submit',
43
+ 'approved' => 'approve',
44
+ 'approve' => 'approve',
45
+ 'rejected' => 'reject',
46
+ 'reject' => 'reject',
47
+ 'published' => 'publish',
48
+ 'publish' => 'publish'
49
+ }.freeze
50
+
51
+ # ── List and find ───────────────────────────────────────────
52
+
53
+ ##
54
+ # Lists all libraries for a given property.
55
+ # Follows pagination automatically — returns all libraries.
56
+ #
57
+ # @param property_id [String] Adobe property ID
58
+ # @return [Array<ReactorSDK::Resources::Library>]
59
+ # @raise [ReactorSDK::ResourceNotFoundError] if the property does not exist
60
+ #
61
+ def list_for_property(property_id)
62
+ records = @paginator.all("/properties/#{property_id}/libraries")
63
+ records.map { |r| @parser.parse(r, Resources::Library) }
64
+ end
65
+
66
+ ##
67
+ # Retrieves a single library by its Adobe ID.
68
+ #
69
+ # @param library_id [String] Adobe library ID (format: "LB" + hex string)
70
+ # @return [ReactorSDK::Resources::Library]
71
+ # @raise [ReactorSDK::ResourceNotFoundError] if the library does not exist
72
+ #
73
+ def find(library_id)
74
+ response = @connection.get("/libraries/#{library_id}")
75
+ @parser.parse(response['data'], Resources::Library)
76
+ end
77
+
78
+ ##
79
+ # Fetches a library with all its associated resources included.
80
+ #
81
+ # Calls GET /libraries/:id?include=rules,data_elements,extensions
82
+ # which returns the library alongside all its resources in the
83
+ # JSON:API included array. Each included resource carries its
84
+ # current revision ID in its relationships hash.
85
+ #
86
+ # Returns a LibraryWithResources object exposing:
87
+ # - All standard library attributes
88
+ # - rules, data_elements, extensions arrays with revision_id attached
89
+ # - resource_index for quick revision ID lookup by resource ID
90
+ #
91
+ # @param library_id [String] Adobe library ID
92
+ # @return [ReactorSDK::Resources::LibraryWithResources]
93
+ # @raise [ReactorSDK::ResourceNotFoundError] if the library does not exist
94
+ #
95
+ def find_with_resources(library_id)
96
+ response = @connection.get(
97
+ "/libraries/#{library_id}",
98
+ params: { 'include' => 'rules,data_elements,extensions' }
99
+ )
100
+ build_library_with_resources(response)
101
+ end
102
+
103
+ ##
104
+ # Fetches a library snapshot enriched with associated records needed
105
+ # for review workflows.
106
+ #
107
+ # This additive helper builds on find_with_resources and then resolves
108
+ # rule components for every rule present in the library snapshot.
109
+ #
110
+ # The returned object is intentionally built fresh on each call so
111
+ # review workflows never read stale snapshot state after writes.
112
+ #
113
+ # @param library_id [String] Adobe library ID
114
+ # @param property_id [String] Adobe property ID containing the library
115
+ # @return [ReactorSDK::Resources::LibrarySnapshot]
116
+ #
117
+ def find_snapshot(library_id, property_id:)
118
+ snapshot_builder.build(library_id, property_id: property_id)
119
+ end
120
+
121
+ ##
122
+ # Compares two libraries and returns per-resource review details that
123
+ # can be passed directly into Changeset-style diff tooling.
124
+ #
125
+ # The first library is treated as the current version and the second
126
+ # library is treated as the baseline version. Changeset-style document
127
+ # helpers therefore map baseline content to old_content and current
128
+ # content to new_content.
129
+ #
130
+ # @param current_library_id [String] Library being reviewed
131
+ # @param baseline_library_id [String] Library used as the comparison baseline
132
+ # @param property_id [String] Adobe property ID containing both libraries
133
+ # @return [ReactorSDK::Resources::LibraryComparison]
134
+ #
135
+ def compare(current_library_id, baseline_library_id:, property_id:)
136
+ comparison_builder.build(
137
+ current_library_id,
138
+ baseline_library_id: baseline_library_id,
139
+ property_id: property_id
140
+ )
141
+ end
142
+
143
+ # ── Create ──────────────────────────────────────────────────
144
+
145
+ ##
146
+ # Creates a new library within a property.
147
+ #
148
+ # @param property_id [String] Adobe property ID
149
+ # @param name [String] Display name for the library
150
+ # @return [ReactorSDK::Resources::Library] The newly created library
151
+ # @raise [ReactorSDK::UnprocessableEntityError] if attributes are invalid
152
+ #
153
+ def create(property_id:, name:)
154
+ payload = build_payload('libraries', { name: name })
155
+ response = @connection.post("/properties/#{property_id}/libraries", payload)
156
+ @parser.parse(response['data'], Resources::Library)
157
+ end
158
+
159
+ ##
160
+ # Updates a library's attributes.
161
+ #
162
+ # @param library_id [String]
163
+ # @param attributes [Hash]
164
+ # @return [ReactorSDK::Resources::Library]
165
+ #
166
+ def update(library_id, attributes)
167
+ update_resource(
168
+ "/libraries/#{library_id}",
169
+ library_id,
170
+ 'libraries',
171
+ Resources::Library,
172
+ attributes: attributes
173
+ )
174
+ end
175
+
176
+ ##
177
+ # Deletes a library.
178
+ #
179
+ # @param library_id [String]
180
+ # @return [nil]
181
+ #
182
+ def delete(library_id)
183
+ delete_resource("/libraries/#{library_id}")
184
+ end
185
+
186
+ ##
187
+ # Retrieves the property that owns a library.
188
+ #
189
+ # @param library_id [String]
190
+ # @return [ReactorSDK::Resources::Property]
191
+ #
192
+ def property(library_id)
193
+ fetch_resource("/libraries/#{library_id}/property", Resources::Property)
194
+ end
195
+
196
+ # ── Rules relationship management ───────────────────────────
197
+
198
+ ##
199
+ # Adds rules to a library.
200
+ # Existing rules in the library are preserved — only the specified
201
+ # rules are added.
202
+ #
203
+ # @param library_id [String] Adobe library ID
204
+ # @param rule_ids [Array<String>] Adobe rule IDs to add
205
+ # @return [nil]
206
+ # @raise [ReactorSDK::ResourceNotFoundError] if the library does not exist
207
+ #
208
+ def add_rules(library_id, rule_ids)
209
+ payload = build_relationship_payload('rules', rule_ids)
210
+ @connection.post("/libraries/#{library_id}/relationships/rules", payload)
211
+ nil
212
+ end
213
+
214
+ ##
215
+ # Removes specific rules from a library.
216
+ # Only the specified rules are removed — other rules are preserved.
217
+ #
218
+ # @param library_id [String] Adobe library ID
219
+ # @param rule_ids [Array<String>] Adobe rule IDs to remove
220
+ # @return [nil]
221
+ # @raise [ReactorSDK::ResourceNotFoundError] if the library does not exist
222
+ #
223
+ def remove_rules(library_id, rule_ids)
224
+ payload = build_relationship_payload('rules', rule_ids)
225
+ @connection.delete_relationship("/libraries/#{library_id}/relationships/rules", payload)
226
+ nil
227
+ end
228
+
229
+ ##
230
+ # Replaces the entire rules list for a library.
231
+ # Any rule currently in the library that is NOT in rule_ids is removed.
232
+ # Passing an empty array removes all rules from the library.
233
+ #
234
+ # Use with caution — this is a destructive operation.
235
+ # Prefer add_rules and remove_rules for incremental changes.
236
+ #
237
+ # @param library_id [String] Adobe library ID
238
+ # @param rule_ids [Array<String>] Complete new list of Adobe rule IDs
239
+ # @return [nil]
240
+ # @raise [ReactorSDK::ResourceNotFoundError] if the library does not exist
241
+ #
242
+ def set_rules(library_id, rule_ids)
243
+ payload = build_relationship_payload('rules', rule_ids)
244
+ @connection.patch("/libraries/#{library_id}/relationships/rules", payload)
245
+ nil
246
+ end
247
+
248
+ ##
249
+ # Lists the rules currently assigned to a library.
250
+ #
251
+ # @param library_id [String]
252
+ # @return [Array<ReactorSDK::Resources::Rule>]
253
+ #
254
+ def rules(library_id)
255
+ list_resources("/libraries/#{library_id}/rules", Resources::Rule)
256
+ end
257
+
258
+ ##
259
+ # Retrieves raw rule relationship linkage for a library.
260
+ #
261
+ # @param library_id [String]
262
+ # @return [Hash, Array<Hash>, nil]
263
+ #
264
+ def rule_relationships(library_id)
265
+ fetch_relationship("/libraries/#{library_id}/relationships/rules")
266
+ end
267
+
268
+ # ── Data elements relationship management ───────────────────
269
+
270
+ ##
271
+ # Adds data elements to a library.
272
+ # Existing data elements in the library are preserved.
273
+ #
274
+ # @param library_id [String] Adobe library ID
275
+ # @param data_element_ids [Array<String>] Adobe data element IDs to add
276
+ # @return [nil]
277
+ # @raise [ReactorSDK::ResourceNotFoundError] if the library does not exist
278
+ #
279
+ def add_data_elements(library_id, data_element_ids)
280
+ payload = build_relationship_payload('data_elements', data_element_ids)
281
+ @connection.post("/libraries/#{library_id}/relationships/data_elements", payload)
282
+ nil
283
+ end
284
+
285
+ ##
286
+ # Removes specific data elements from a library.
287
+ # Only the specified data elements are removed — others are preserved.
288
+ #
289
+ # @param library_id [String] Adobe library ID
290
+ # @param data_element_ids [Array<String>] Adobe data element IDs to remove
291
+ # @return [nil]
292
+ # @raise [ReactorSDK::ResourceNotFoundError] if the library does not exist
293
+ #
294
+ def remove_data_elements(library_id, data_element_ids)
295
+ payload = build_relationship_payload('data_elements', data_element_ids)
296
+ @connection.delete_relationship("/libraries/#{library_id}/relationships/data_elements", payload)
297
+ nil
298
+ end
299
+
300
+ ##
301
+ # Replaces the entire data elements list for a library.
302
+ # Any data element currently in the library that is NOT in
303
+ # data_element_ids is removed.
304
+ # Passing an empty array removes all data elements from the library.
305
+ #
306
+ # Use with caution — this is a destructive operation.
307
+ #
308
+ # @param library_id [String] Adobe library ID
309
+ # @param data_element_ids [Array<String>] Complete new list of data element IDs
310
+ # @return [nil]
311
+ # @raise [ReactorSDK::ResourceNotFoundError] if the library does not exist
312
+ #
313
+ def set_data_elements(library_id, data_element_ids)
314
+ payload = build_relationship_payload('data_elements', data_element_ids)
315
+ @connection.patch("/libraries/#{library_id}/relationships/data_elements", payload)
316
+ nil
317
+ end
318
+
319
+ ##
320
+ # Lists the data elements currently assigned to a library.
321
+ #
322
+ # @param library_id [String]
323
+ # @return [Array<ReactorSDK::Resources::DataElement>]
324
+ #
325
+ def data_elements(library_id)
326
+ list_resources("/libraries/#{library_id}/data_elements", Resources::DataElement)
327
+ end
328
+
329
+ ##
330
+ # Retrieves raw data element relationship linkage for a library.
331
+ #
332
+ # @param library_id [String]
333
+ # @return [Hash, Array<Hash>, nil]
334
+ #
335
+ def data_element_relationships(library_id)
336
+ fetch_relationship("/libraries/#{library_id}/relationships/data_elements")
337
+ end
338
+
339
+ # ── Extensions relationship management ──────────────────────
340
+
341
+ ##
342
+ # Adds extensions to a library.
343
+ # Existing extensions in the library are preserved.
344
+ #
345
+ # @param library_id [String] Adobe library ID
346
+ # @param extension_ids [Array<String>] Adobe extension IDs to add
347
+ # @return [nil]
348
+ # @raise [ReactorSDK::ResourceNotFoundError] if the library does not exist
349
+ #
350
+ def add_extensions(library_id, extension_ids)
351
+ payload = build_relationship_payload('extensions', extension_ids)
352
+ @connection.post("/libraries/#{library_id}/relationships/extensions", payload)
353
+ nil
354
+ end
355
+
356
+ ##
357
+ # Removes specific extensions from a library.
358
+ # Only the specified extensions are removed — others are preserved.
359
+ #
360
+ # @param library_id [String] Adobe library ID
361
+ # @param extension_ids [Array<String>] Adobe extension IDs to remove
362
+ # @return [nil]
363
+ # @raise [ReactorSDK::ResourceNotFoundError] if the library does not exist
364
+ #
365
+ def remove_extensions(library_id, extension_ids)
366
+ payload = build_relationship_payload('extensions', extension_ids)
367
+ @connection.delete_relationship("/libraries/#{library_id}/relationships/extensions", payload)
368
+ nil
369
+ end
370
+
371
+ ##
372
+ # Replaces the entire extensions list for a library.
373
+ # Any extension currently in the library that is NOT in extension_ids
374
+ # is removed. Passing an empty array removes all extensions.
375
+ #
376
+ # Use with caution — this is a destructive operation.
377
+ #
378
+ # @param library_id [String] Adobe library ID
379
+ # @param extension_ids [Array<String>] Complete new list of extension IDs
380
+ # @return [nil]
381
+ # @raise [ReactorSDK::ResourceNotFoundError] if the library does not exist
382
+ #
383
+ def set_extensions(library_id, extension_ids)
384
+ payload = build_relationship_payload('extensions', extension_ids)
385
+ @connection.patch("/libraries/#{library_id}/relationships/extensions", payload)
386
+ nil
387
+ end
388
+
389
+ ##
390
+ # Lists the extensions currently assigned to a library.
391
+ #
392
+ # @param library_id [String]
393
+ # @return [Array<ReactorSDK::Resources::Extension>]
394
+ #
395
+ def extensions(library_id)
396
+ list_resources("/libraries/#{library_id}/extensions", Resources::Extension)
397
+ end
398
+
399
+ ##
400
+ # Retrieves raw extension relationship linkage for a library.
401
+ #
402
+ # @param library_id [String]
403
+ # @return [Hash, Array<Hash>, nil]
404
+ #
405
+ def extension_relationships(library_id)
406
+ fetch_relationship("/libraries/#{library_id}/relationships/extensions")
407
+ end
408
+
409
+ # ── Environment assignment ──────────────────────────────────
410
+
411
+ ##
412
+ # Assigns an environment to a library.
413
+ # A library must have an environment assigned before it can be built.
414
+ #
415
+ # @param library_id [String] Adobe library ID
416
+ # @param environment_id [String] Adobe environment ID to assign
417
+ # @return [nil]
418
+ # @raise [ReactorSDK::ResourceNotFoundError] if either resource does not exist
419
+ #
420
+ def assign_environment(library_id, environment_id)
421
+ payload = { data: { id: environment_id, type: 'environments' } }
422
+ @connection.patch("/libraries/#{library_id}/relationships/environment", payload)
423
+ nil
424
+ end
425
+
426
+ ##
427
+ # Retrieves the environment currently assigned to a library.
428
+ #
429
+ # @param library_id [String]
430
+ # @return [ReactorSDK::Resources::Environment]
431
+ #
432
+ def environment(library_id)
433
+ fetch_resource("/libraries/#{library_id}/environment", Resources::Environment)
434
+ end
435
+
436
+ ##
437
+ # Retrieves the raw environment relationship for a library.
438
+ #
439
+ # @param library_id [String]
440
+ # @return [Hash, Array<Hash>, nil]
441
+ #
442
+ def environment_relationship(library_id)
443
+ fetch_relationship("/libraries/#{library_id}/relationships/environment")
444
+ end
445
+
446
+ ##
447
+ # Removes any assigned environment from a library.
448
+ #
449
+ # @param library_id [String]
450
+ # @return [nil]
451
+ #
452
+ def remove_environment(library_id)
453
+ delete_resource("/libraries/#{library_id}/relationships/environment")
454
+ end
455
+
456
+ # ── State machine ───────────────────────────────────────────
457
+
458
+ ##
459
+ # Transitions a library to a new state in its workflow.
460
+ #
461
+ # Valid transitions:
462
+ # development -> submitted -> approved -> published
463
+ # -> rejected -> development
464
+ #
465
+ # @param library_id [String] Adobe library ID
466
+ # @param state [String] Target state to transition to
467
+ # @return [ReactorSDK::Resources::Library] The updated library
468
+ # @raise [ReactorSDK::UnprocessableEntityError] if the transition is invalid
469
+ #
470
+ def transition(library_id, state:)
471
+ payload = {
472
+ data: {
473
+ id: library_id,
474
+ type: 'libraries',
475
+ meta: { action: normalize_transition_action(state) }
476
+ }
477
+ }
478
+ response = @connection.patch("/libraries/#{library_id}", payload)
479
+ @parser.parse(response['data'], Resources::Library)
480
+ end
481
+
482
+ # ── Build ───────────────────────────────────────────────────
483
+
484
+ ##
485
+ # Triggers a build for a library.
486
+ # The library must be in "development" state and have an environment assigned.
487
+ #
488
+ # @param library_id [String] Adobe library ID
489
+ # @return [ReactorSDK::Resources::Build] The triggered build
490
+ # @raise [ReactorSDK::UnprocessableEntityError] if the library cannot be built
491
+ #
492
+ def build(library_id)
493
+ response = @connection.post("/libraries/#{library_id}/builds", {})
494
+ @parser.parse(response['data'], Resources::Build)
495
+ end
496
+
497
+ ##
498
+ # Retrieves the immediate upstream library configured for a library.
499
+ #
500
+ # @param library_id [String]
501
+ # @return [ReactorSDK::Resources::Library]
502
+ #
503
+ def upstream_library(library_id)
504
+ fetch_resource("/libraries/#{library_id}/upstream_library", Resources::Library)
505
+ end
506
+
507
+ ##
508
+ # Lists notes attached to a library.
509
+ #
510
+ # @param library_id [String]
511
+ # @return [Array<ReactorSDK::Resources::Note>]
512
+ #
513
+ def list_notes(library_id)
514
+ list_notes_for_path("/libraries/#{library_id}/notes")
515
+ end
516
+
517
+ ##
518
+ # Creates a note on a library.
519
+ #
520
+ # @param library_id [String]
521
+ # @param text [String]
522
+ # @return [ReactorSDK::Resources::Note]
523
+ #
524
+ def create_note(library_id, text)
525
+ create_note_for_path("/libraries/#{library_id}/notes", text)
526
+ end
527
+
528
+ # ── Upstream resolution ─────────────────────────────────────
529
+
530
+ ##
531
+ # Returns the ordered list of libraries upstream of the given library.
532
+ #
533
+ # Adobe Launch uses an environment hierarchy where changes flow upward:
534
+ # Personal Dev → Development → Staging → Production
535
+ #
536
+ # "Upstream" means closer to Production. When a resource does not exist
537
+ # in the target library, the app walks this list in order to find the
538
+ # nearest upstream version to diff against.
539
+ #
540
+ # Examples:
541
+ # Target is Development → returns [staging_library, production_library]
542
+ # Target is Staging → returns [production_library]
543
+ # Target is Production → returns [] (nothing upstream)
544
+ #
545
+ # @param library_id [String] Adobe library ID of the target library
546
+ # @param property_id [String] Adobe property ID
547
+ # @return [Array<ReactorSDK::Resources::Library>] Upstream libraries, nearest first
548
+ # @raise [ReactorSDK::ResourceNotFoundError] if either resource does not exist
549
+ #
550
+ def upstream_libraries(library_id, property_id:)
551
+ find(library_id)
552
+ target_stage = fetch_library_stage(library_id)
553
+
554
+ return [] if target_stage.nil?
555
+ return [] if target_stage == 'production'
556
+
557
+ upstream_stages = stages_above(target_stage)
558
+ all_libraries = list_for_property(property_id)
559
+
560
+ upstream_stages.filter_map do |stage|
561
+ all_libraries.find do |lib|
562
+ fetch_library_stage(lib.id) == stage
563
+ end
564
+ end
565
+ end
566
+
567
+ ##
568
+ # Resolves a single resource across the ordered upstream library chain.
569
+ #
570
+ # This is the resource-level convenience wrapper built on top of
571
+ # upstream_libraries and find_with_resources. It allows callers to ask
572
+ # for upstream information directly from a rule, data element, or
573
+ # extension ID without hand-rolling the traversal loop.
574
+ #
575
+ # The returned UpstreamChain object includes:
576
+ # - the target library context
577
+ # - the target resource and target revision_id when present
578
+ # - one UpstreamChainEntry per upstream library, nearest first
579
+ #
580
+ # @param resource_or_id [String, ReactorSDK::Resources::BaseResource]
581
+ # @param library_id [String] Adobe library ID used as the comparison root
582
+ # @param property_id [String] Adobe property ID containing the library chain
583
+ # @param resource_type [String, nil] Optional JSON:API type hint
584
+ # @return [ReactorSDK::Resources::UpstreamChain]
585
+ #
586
+ def upstream_chain_for_resource(resource_or_id, library_id:, property_id:, resource_type: nil)
587
+ resource_id = extract_resource_id(resource_or_id)
588
+ target_library = find_with_resources(library_id)
589
+ target_resource = target_library.all_resources.find { |resource| resource.id == resource_id }
590
+ target_revision_id = target_library.resource_index[resource_id]
591
+
592
+ entries = upstream_libraries(library_id, property_id: property_id).map do |library|
593
+ build_upstream_chain_entry(library, resource_id)
594
+ end
595
+
596
+ Resources::UpstreamChain.new(
597
+ resource_id: resource_id,
598
+ resource_type: resource_type || extract_resource_type(resource_or_id) || target_resource&.type,
599
+ property_id: property_id,
600
+ target_library_id: library_id,
601
+ target_resource: target_resource,
602
+ target_revision_id: target_revision_id,
603
+ entries: entries
604
+ )
605
+ end
606
+
607
+ ##
608
+ # Resolves a resource across the ordered upstream library chain using
609
+ # snapshot-aware comprehensive resource wrappers.
610
+ #
611
+ # @param resource_or_id [String, ReactorSDK::Resources::BaseResource]
612
+ # @param library_id [String]
613
+ # @param property_id [String]
614
+ # @param resource_type [String, nil]
615
+ # @return [ReactorSDK::Resources::ComprehensiveUpstreamChain]
616
+ #
617
+ def comprehensive_upstream_chain_for_resource(resource_or_id, library_id:, property_id:, resource_type: nil)
618
+ resource_id = extract_resource_id(resource_or_id)
619
+ snapshot_cache = {}
620
+ target_context = resolve_comprehensive_target_context(
621
+ resource_or_id,
622
+ resource_id,
623
+ library_id,
624
+ property_id,
625
+ snapshot_cache,
626
+ resource_type
627
+ )
628
+
629
+ build_comprehensive_upstream_chain(
630
+ resource_id,
631
+ library_id: library_id,
632
+ property_id: property_id,
633
+ target_context: target_context,
634
+ snapshot_cache: snapshot_cache
635
+ )
636
+ end
637
+
638
+ private
639
+
640
+ ##
641
+ # Builds a LibraryWithResources from a full API response that includes
642
+ # rules, data_elements, and extensions in the included array.
643
+ #
644
+ # @param response [Hash] Full JSON:API response from the API
645
+ # @return [ReactorSDK::Resources::LibraryWithResources]
646
+ #
647
+ def build_library_with_resources(response)
648
+ data = response.fetch('data')
649
+ included = Array(response['included'])
650
+
651
+ included_by_type = included.group_by { |r| r['type'] }
652
+
653
+ Resources::LibraryWithResources.new(
654
+ id: data.fetch('id'),
655
+ type: data.fetch('type'),
656
+ attributes: data.fetch('attributes', {}),
657
+ meta: data.fetch('meta', {}),
658
+ included_resources: {
659
+ 'rules' => included_by_type.fetch('rules', []),
660
+ 'data_elements' => included_by_type.fetch('data_elements', []),
661
+ 'extensions' => included_by_type.fetch('extensions', [])
662
+ }
663
+ )
664
+ end
665
+
666
+ ##
667
+ # Fetches the environment stage for a library by following its
668
+ # environment relationship.
669
+ #
670
+ # @param library_id [String] Adobe library ID
671
+ # @return [String, nil] Stage ("development", "staging", "production") or nil
672
+ #
673
+ def fetch_library_stage(library_id)
674
+ env_rel = @connection.get("/libraries/#{library_id}/relationships/environment")
675
+ env_id = env_rel&.dig('data', 'id')
676
+ return nil unless env_id
677
+
678
+ env_response = @connection.get("/environments/#{env_id}")
679
+ env_response&.dig('data', 'attributes', 'stage')
680
+ end
681
+
682
+ ##
683
+ # Returns the stages upstream of the given stage, nearest first.
684
+ #
685
+ # @param stage [String] Current stage
686
+ # @return [Array<String>] Upstream stages in order
687
+ #
688
+ def stages_above(stage)
689
+ current_index = UPSTREAM_STAGE_ORDER.index(stage)
690
+ return [] if current_index.nil?
691
+
692
+ UPSTREAM_STAGE_ORDER[(current_index + 1)..]
693
+ end
694
+
695
+ ##
696
+ # Builds one upstream-chain entry for a specific library and resource ID.
697
+ #
698
+ # @param library [ReactorSDK::Resources::Library]
699
+ # @param resource_id [String]
700
+ # @return [ReactorSDK::Resources::UpstreamChainEntry]
701
+ #
702
+ def build_upstream_chain_entry(library, resource_id)
703
+ library_with_resources = find_with_resources(library.id)
704
+ matched_resource = library_with_resources.all_resources.find { |resource| resource.id == resource_id }
705
+ revision_id = library_with_resources.resource_index[resource_id]
706
+ revision = revision_id ? revisions_endpoint.find(revision_id) : nil
707
+
708
+ Resources::UpstreamChainEntry.new(
709
+ library: library,
710
+ stage: fetch_library_stage(library.id),
711
+ resource: matched_resource,
712
+ revision_id: revision_id,
713
+ revision: revision
714
+ )
715
+ end
716
+
717
+ def build_comprehensive_upstream_chain_entry(library, resource_id, property_id:, resource_type:, snapshot_cache:)
718
+ snapshot = fetch_snapshot(library.id, property_id: property_id, cache: snapshot_cache)
719
+ resource = snapshot.find_resource(resource_id)
720
+ revision_id = snapshot.resource_revision_id(resource_id)
721
+ revision = revision_id ? revisions_endpoint.find(revision_id) : nil
722
+
723
+ Resources::ComprehensiveUpstreamChainEntry.new(
724
+ library: library,
725
+ stage: fetch_library_stage(library.id),
726
+ resource: resource,
727
+ revision_id: revision_id,
728
+ revision: revision,
729
+ comprehensive_resource: snapshot.comprehensive_resource(resource_id, resource_type: resource_type)
730
+ )
731
+ end
732
+
733
+ def resolve_comprehensive_target_context(
734
+ resource_or_id,
735
+ resource_id,
736
+ library_id,
737
+ property_id,
738
+ snapshot_cache,
739
+ resource_type
740
+ )
741
+ snapshot = fetch_snapshot(library_id, property_id: property_id, cache: snapshot_cache)
742
+ build_comprehensive_target_context(resource_or_id, resource_id, snapshot, resource_type)
743
+ end
744
+
745
+ def build_comprehensive_target_context(resource_or_id, resource_id, snapshot, resource_type)
746
+ resource = snapshot.find_resource(resource_id)
747
+ resolved_resource_type = resource_type || extract_resource_type(resource_or_id) || resource&.type
748
+
749
+ {
750
+ resource: resource,
751
+ resource_type: resolved_resource_type,
752
+ revision_id: snapshot.resource_revision_id(resource_id),
753
+ comprehensive_resource: snapshot.comprehensive_resource(resource_id, resource_type: resolved_resource_type)
754
+ }
755
+ end
756
+
757
+ def build_comprehensive_upstream_chain(resource_id, library_id:, property_id:, target_context:, snapshot_cache:)
758
+ Resources::ComprehensiveUpstreamChain.new(
759
+ resource_id: resource_id,
760
+ resource_type: target_context.fetch(:resource_type),
761
+ property_id: property_id,
762
+ target_library_id: library_id,
763
+ target_resource: target_context.fetch(:resource),
764
+ target_revision_id: target_context.fetch(:revision_id),
765
+ target_comprehensive_resource: target_context.fetch(:comprehensive_resource),
766
+ entries: build_comprehensive_upstream_entries(
767
+ library_id,
768
+ property_id,
769
+ resource_id,
770
+ target_context.fetch(:resource_type),
771
+ snapshot_cache
772
+ )
773
+ )
774
+ end
775
+
776
+ def build_comprehensive_upstream_entries(library_id, property_id, resource_id, resource_type, snapshot_cache)
777
+ upstream_libraries(library_id, property_id: property_id).map do |library|
778
+ build_comprehensive_upstream_chain_entry(
779
+ library,
780
+ resource_id,
781
+ property_id: property_id,
782
+ resource_type: resource_type,
783
+ snapshot_cache: snapshot_cache
784
+ )
785
+ end
786
+ end
787
+
788
+ def fetch_snapshot(library_id, property_id:, cache:)
789
+ cache.fetch(snapshot_cache_key(library_id, property_id)) do
790
+ cache[snapshot_cache_key(library_id, property_id)] = find_snapshot(library_id, property_id: property_id)
791
+ end
792
+ end
793
+
794
+ ##
795
+ # Normalizes a resource object or raw resource ID into an Adobe ID string.
796
+ #
797
+ # @param resource_or_id [String, #id]
798
+ # @return [String]
799
+ #
800
+ def extract_resource_id(resource_or_id)
801
+ return resource_or_id.id if resource_or_id.respond_to?(:id)
802
+
803
+ resource_or_id
804
+ end
805
+
806
+ ##
807
+ # Reads the resource type from a resource object when available.
808
+ #
809
+ # @param resource_or_id [String, #type]
810
+ # @return [String, nil]
811
+ #
812
+ def extract_resource_type(resource_or_id)
813
+ return resource_or_id.type if resource_or_id.respond_to?(:type)
814
+
815
+ nil
816
+ end
817
+
818
+ ##
819
+ # Builds a lightweight revisions endpoint sharing this endpoint's deps.
820
+ #
821
+ # @return [ReactorSDK::Endpoints::Revisions]
822
+ #
823
+ def revisions_endpoint
824
+ @revisions_endpoint ||= Endpoints::Revisions.new(
825
+ connection: @connection,
826
+ paginator: @paginator,
827
+ parser: @parser
828
+ )
829
+ end
830
+
831
+ def rule_components_endpoint
832
+ @rule_components_endpoint ||= Endpoints::RuleComponents.new(
833
+ connection: @connection,
834
+ paginator: @paginator,
835
+ parser: @parser
836
+ )
837
+ end
838
+
839
+ def snapshot_builder
840
+ @snapshot_builder ||= ReactorSDK::LibrarySnapshotBuilder.new(
841
+ library_loader: method(:find_with_resources),
842
+ revisions_endpoint: revisions_endpoint,
843
+ rule_components_endpoint: rule_components_endpoint
844
+ )
845
+ end
846
+
847
+ def comparison_builder
848
+ @comparison_builder ||= ReactorSDK::LibraryComparisonBuilder.new(
849
+ snapshot_loader: method(:find_snapshot)
850
+ )
851
+ end
852
+
853
+ def snapshot_cache_key(library_id, property_id)
854
+ "#{property_id}:#{library_id}"
855
+ end
856
+
857
+ ##
858
+ # Converts a library state or action alias into the API's expected action.
859
+ #
860
+ # @param state [String, Symbol]
861
+ # @return [String]
862
+ # @raise [ArgumentError] if the transition is unknown
863
+ #
864
+ def normalize_transition_action(state)
865
+ TRANSITION_ACTIONS.fetch(state.to_s) do
866
+ valid = TRANSITION_ACTIONS.keys.uniq.sort.join(', ')
867
+ raise ArgumentError, "Unknown library transition: #{state.inspect}. Expected one of: #{valid}"
868
+ end
869
+ end
870
+ end
871
+ end
872
+ end