atlas_rb 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9660fb0f48db5601e75f8fb0579794cd398d42fbdbe1c2e849b67bb32569d9c1
4
- data.tar.gz: fa8f43e4f7577027c3368e30f4ebf5c231202edbcaa1004ef0711582925680ee
3
+ metadata.gz: 93e0688ad6097a3b6a36ed26db8c19d3416cb3c59beb3d95dd3cff61c6cdd176
4
+ data.tar.gz: 1af0ff155d288412bd6ab8a95fc50da55cfccd02d605b5bae1cf28cebdf33973
5
5
  SHA512:
6
- metadata.gz: b98eab79df77eda36139468d6701ea8e56f5ae16ee85e3ef22635bc11f42aed762654bd9dbf02b87a4f8521bb4de335724b249c3700397d748572bd734bf62b4
7
- data.tar.gz: c8f565644a54f99e6110131e6d1b361c21f51e068c565837c5226d21eb700b488fa13e1be9e59d591b587b4303196c785abf0c1fc8bb5eec000840b1515f2961
6
+ metadata.gz: 44c8582605f238861c424a769fd4e788e08189edd969f3974d113a938f54ecce522e143ec0418cd813de958c1cbb6d9cd44b4796b6b9c190629af49a1d0417e9
7
+ data.tar.gz: 8ad34a6b44d4472ba300f8ef7b6c9f30d5d8674f0f69f7711e094d202ba07f2b428607b463ce8261e21e2fa5bbcbca865da9de3fb3af8e28d6e8906810037a12
data/.version CHANGED
@@ -1 +1 @@
1
- 1.1.1
1
+ 1.2.0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0
4
+
5
+ ### Added — Tree/DAG foundation bindings
6
+
7
+ Thin Faraday mirrors for the two membership mutations Atlas shipped as
8
+ part of the DRS "Tree/DAG foundation" (re-parenting and linked members).
9
+ No client-side logic — the gem mirrors Atlas's wire and never queries
10
+ Solr.
11
+
12
+ - **`AtlasRb::Collection.reparent(id, new_parent_id, nuid: nil, on_behalf_of: nil)`**
13
+ - **`AtlasRb::Community.reparent(id, new_parent_id, nuid: nil, on_behalf_of: nil)`**
14
+ - **`AtlasRb::Work.reparent(id, new_collection_id, nuid: nil, on_behalf_of: nil)`**
15
+
16
+ Bind `PATCH /<type>/:id/parent` with a `{ parent_id }` body, moving a
17
+ resource to a new structural parent. Mirrors `create`'s single-parent-id
18
+ shape and returns the updated resource (same shape as `find`), reflecting
19
+ the new `a_member_of`. `Community.reparent` accepts `new_parent_id: nil`
20
+ to promote a Community to the top of the tree — the same way
21
+ `Community.create(nil)` makes a top-level Community; a `nil` destination
22
+ is rejected by Atlas for Works and Collections. Atlas enforces the
23
+ structural rules (type, cycle, tombstone) server-side and synchronously
24
+ cascades the ancestry index over descendants, surfacing violations as
25
+ `422`. The Work re-parent endpoint is included — Atlas shipped it (the
26
+ plan had flagged it as optional). All three endpoints use the shared
27
+ `parent_id` body key, including the Work one (not `collection_id`).
28
+
29
+ - **`AtlasRb::Work.linked_members(id, nuid: nil, on_behalf_of: nil)`** —
30
+ `GET /works/:id/linked_members`.
31
+ - **`AtlasRb::Work.add_linked_member(work_id, collection_id, nuid: nil, on_behalf_of: nil)`** —
32
+ `POST /works/:id/linked_members` with a `{ collection_id }` body.
33
+ - **`AtlasRb::Work.remove_linked_member(work_id, collection_id, nuid: nil, on_behalf_of: nil)`** —
34
+ `DELETE /works/:id/linked_members/:collection_id` (Collection as a path
35
+ segment).
36
+
37
+ The DAG overlay: a Work has one structural parent (`a_member_of`) but
38
+ may additionally be a *linked* member of any number of other Collections
39
+ (`a_linked_member_of`). These manage that overlay without moving the
40
+ Work. All three return the Work's current linked Collection noids as a
41
+ bare array (mirroring `Collection.children`); the two mutations return
42
+ the list *after* the change, so no follow-up GET is needed.
43
+
44
+ Cerberus consumes these from the re-parent and "add to collection" UI.
45
+
3
46
  ## 1.1.1
4
47
 
5
48
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- atlas_rb (1.1.1)
4
+ atlas_rb (1.2.0)
5
5
  faraday (~> 2.7)
6
6
  faraday-follow_redirects (~> 0.3.0)
7
7
  faraday-multipart (~> 1)
@@ -23,7 +23,7 @@ GEM
23
23
  net-http (~> 0.5)
24
24
  hashie (5.1.0)
25
25
  logger
26
- json (2.19.5)
26
+ json (2.19.7)
27
27
  logger (1.7.0)
28
28
  multipart-post (2.4.1)
29
29
  net-http (0.9.1)
data/README.md CHANGED
@@ -205,6 +205,47 @@ result["events"].first["action"] # => "update"
205
205
  Authorization errors (`401` / `403`) are not caught here — they surface as
206
206
  raw Faraday responses for the calling application's rescue layer.
207
207
 
208
+ ### Re-parenting
209
+
210
+ `reparent` moves a resource to a new structural parent, binding Atlas's
211
+ `PATCH /<type>/:id/parent` endpoint. It mirrors `create`'s "single parent
212
+ id" shape and returns the updated resource (same shape as `find`), so the
213
+ caller sees the new `a_member_of` without a follow-up GET. Atlas enforces
214
+ the structural rules (type, cycle, tombstone guards) server-side and
215
+ synchronously cascades the ancestry index over descendants; rule
216
+ violations come back as `422`.
217
+
218
+ ```ruby
219
+ AtlasRb::Collection.reparent("col-456", "c-999") # move collection to community c-999
220
+ AtlasRb::Work.reparent("w-789", "col-999") # move work to collection col-999
221
+ AtlasRb::Community.reparent("c-123", "c-999") # nest community under c-999
222
+ AtlasRb::Community.reparent("c-123", nil) # promote community to top of tree
223
+ ```
224
+
225
+ Only `Community.reparent` accepts a `nil` destination (move to the top of
226
+ the tree) — the same way `Community.create(nil)` makes a top-level
227
+ community. A `nil` destination for a Work or Collection is rejected by
228
+ Atlas.
229
+
230
+ ### Linked members (the DAG overlay)
231
+
232
+ A Work has exactly one structural parent (`a_member_of`, set by `create` /
233
+ `reparent`) but may additionally be a *linked* member of any number of
234
+ other Collections (`a_linked_member_of`). The linked-member bindings on
235
+ `Work` manage that overlay without ever moving the Work:
236
+
237
+ ```ruby
238
+ AtlasRb::Work.linked_members("w-789") # => ["col-456", "col-457"]
239
+ AtlasRb::Work.add_linked_member("w-789", "col-456") # => ["col-456"] (updated list)
240
+ AtlasRb::Work.remove_linked_member("w-789", "col-456") # => [] (updated list)
241
+ ```
242
+
243
+ All three return the Work's current linked Collection noids as a bare
244
+ array (mirroring `Collection.children`); the two mutations return the list
245
+ *after* the change, so no follow-up `linked_members` GET is needed.
246
+ Resolving those Collections' full contents is a Cerberus/Solr concern —
247
+ this gem never queries the index.
248
+
208
249
  ## End-to-end example
209
250
 
210
251
  JSON responses come back as `AtlasRb::Mash` (a `Hashie::Mash` subclass), so
@@ -65,6 +65,39 @@ module AtlasRb
65
65
  find(result["id"], nuid: nuid, on_behalf_of: on_behalf_of)
66
66
  end
67
67
 
68
+ # Move a Collection to a different parent Community.
69
+ #
70
+ # Wraps `PATCH /collections/<id>/parent` with a `parent_id` of the new
71
+ # Community. Atlas re-parents the Collection and synchronously cascades
72
+ # the ancestry index over its Works; the structural rules (type, cycle,
73
+ # tombstone guards) are enforced server-side and surface as a `422`.
74
+ #
75
+ # Mirrors {.create}'s "single parent id" shape — same kwarg threading,
76
+ # the only difference is the verb and that the Collection already exists.
77
+ #
78
+ # @param id [String] the Collection ID to move.
79
+ # @param new_parent_id [String] the destination Community ID.
80
+ # @param nuid [String, nil] optional acting user's NUID, forwarded as the
81
+ # `User:` header. Required for cerberus-token requests; legacy bearer
82
+ # tokens still resolve without it.
83
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
84
+ # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
85
+ # omitted.
86
+ # @return [Hash] the updated `"collection"` object, already unwrapped —
87
+ # the same shape {.find} returns, reflecting the new `a_member_of`.
88
+ # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
89
+ # conflict that exhausted its internal retry budget (HTTP 409 with
90
+ # `error: "stale_resource"`).
91
+ #
92
+ # @example
93
+ # AtlasRb::Collection.reparent("col-456", "c-999")
94
+ def self.reparent(id, new_parent_id, nuid: nil, on_behalf_of: nil)
95
+ AtlasRb::Mash.new(JSON.parse(
96
+ connection({ parent_id: new_parent_id }, nuid, on_behalf_of: on_behalf_of)
97
+ .patch(ROUTE + id + '/parent')&.body
98
+ ))["collection"]
99
+ end
100
+
68
101
  # Tombstone (withdraw) a Collection.
69
102
  #
70
103
  # The Collection remains in Atlas storage but is marked as withdrawn:
@@ -173,6 +206,9 @@ module AtlasRb
173
206
  # `User:` header. Required for cerberus-token requests; legacy bearer
174
207
  # tokens still resolve without it.
175
208
  # @return [AtlasRb::Mash] the parsed JSON response.
209
+ # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
210
+ # conflict that exhausted its internal retry budget (HTTP 409 with
211
+ # `error: "stale_resource"`).
176
212
  #
177
213
  # @example
178
214
  # AtlasRb::Collection.set_thumbnails(
@@ -69,6 +69,45 @@ module AtlasRb
69
69
  find(result["id"], nuid: nuid, on_behalf_of: on_behalf_of)
70
70
  end
71
71
 
72
+ # Move a Community to a different parent Community — or to the top of the
73
+ # tree.
74
+ #
75
+ # Wraps `PATCH /communities/<id>/parent` with a `parent_id` of the new
76
+ # parent Community. Pass `new_parent_id = nil` to promote the Community to
77
+ # a top-level node (no parent) — mirroring how {.create} treats a `nil`
78
+ # `id`; the gem omits the blank param and Atlas reads it as "move to top".
79
+ # Atlas re-parents the Community and synchronously cascades the ancestry
80
+ # index over its descendant Collections and Works; the structural rules
81
+ # (cycle, tombstone guards) are enforced server-side and surface as a
82
+ # `422`.
83
+ #
84
+ # @param id [String] the Community ID to move.
85
+ # @param new_parent_id [String, nil] the destination Community ID, or
86
+ # `nil` to move the Community to the top of the tree.
87
+ # @param nuid [String, nil] optional acting user's NUID, forwarded as the
88
+ # `User:` header. Required for cerberus-token requests; legacy bearer
89
+ # tokens still resolve without it.
90
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
91
+ # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
92
+ # omitted.
93
+ # @return [Hash] the updated `"community"` object, already unwrapped —
94
+ # the same shape {.find} returns, reflecting the new `a_member_of`.
95
+ # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
96
+ # conflict that exhausted its internal retry budget (HTTP 409 with
97
+ # `error: "stale_resource"`).
98
+ #
99
+ # @example Move under another Community
100
+ # AtlasRb::Community.reparent("c-123", "c-999")
101
+ #
102
+ # @example Promote to a top-level Community
103
+ # AtlasRb::Community.reparent("c-123", nil)
104
+ def self.reparent(id, new_parent_id, nuid: nil, on_behalf_of: nil)
105
+ AtlasRb::Mash.new(JSON.parse(
106
+ connection({ parent_id: new_parent_id }, nuid, on_behalf_of: on_behalf_of)
107
+ .patch(ROUTE + id + '/parent')&.body
108
+ ))["community"]
109
+ end
110
+
72
111
  # Tombstone (withdraw) a Community.
73
112
  #
74
113
  # The Community remains in Atlas storage but is marked as withdrawn:
@@ -179,6 +218,9 @@ module AtlasRb
179
218
  # `User:` header. Required for cerberus-token requests; legacy bearer
180
219
  # tokens still resolve without it.
181
220
  # @return [AtlasRb::Mash] the parsed JSON response.
221
+ # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
222
+ # conflict that exhausted its internal retry budget (HTTP 409 with
223
+ # `error: "stale_resource"`).
182
224
  #
183
225
  # @example
184
226
  # AtlasRb::Community.set_thumbnails(
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtlasRb
4
+ # Base error for atlas_rb. Subclassed for specific wire-level conditions
5
+ # that callers want to handle distinctly. Most non-2xx responses still
6
+ # flow through as Mashes today; we mint typed exceptions only where
7
+ # callers genuinely need to discriminate (currently: optimistic-lock
8
+ # conflicts that ActiveJob `retry_on` policies need to key on).
9
+ class Error < StandardError; end
10
+
11
+ # Raised when Atlas responds with HTTP 409 + `error: "stale_resource"`,
12
+ # indicating an optimistic-lock conflict that either (a) exhausted
13
+ # Atlas's internal retry budget for a retry-safe action, or (b) hit a
14
+ # retry-unsafe action and surfaced immediately.
15
+ #
16
+ # Callers (typically ActiveJob subclasses in Cerberus) handle this via:
17
+ #
18
+ # retry_on AtlasRb::StaleResourceError, attempts: 5, wait: :polynomially_longer
19
+ #
20
+ # The exception carries the resource_id and action from Atlas's envelope
21
+ # so failure logs are useful without needing the full HTTP response.
22
+ class StaleResourceError < Error
23
+ # @return [String, nil] the conflicted resource's ID, from the envelope.
24
+ attr_reader :resource_id
25
+
26
+ # @return [String, nil] the controller action that conflicted, from the
27
+ # envelope (e.g. `"update_thumbnails"`).
28
+ attr_reader :action
29
+
30
+ # @param message [String] human-readable conflict description.
31
+ # @param resource_id [String, nil] the conflicted resource's ID.
32
+ # @param action [String, nil] the controller action that conflicted.
33
+ def initialize(message, resource_id: nil, action: nil)
34
+ super(message)
35
+ @resource_id = resource_id
36
+ @action = action
37
+ end
38
+ end
39
+ end
@@ -59,6 +59,7 @@ module AtlasRb
59
59
  params: params,
60
60
  headers: headers
61
61
  ) do |f|
62
+ f.use AtlasRb::Middleware::RaiseOnStaleResource
62
63
  f.response :follow_redirects
63
64
  f.adapter Faraday.default_adapter
64
65
  end
@@ -103,6 +104,7 @@ module AtlasRb
103
104
  url: ENV.fetch("ATLAS_URL", nil),
104
105
  headers: headers
105
106
  ) do |f|
107
+ f.use AtlasRb::Middleware::RaiseOnStaleResource
106
108
  f.request :multipart
107
109
  f.request :url_encoded
108
110
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtlasRb
4
+ # Faraday middleware namespace.
5
+ module Middleware
6
+ # Translates Atlas's structured optimistic-lock conflict response into a
7
+ # typed Ruby exception.
8
+ #
9
+ # Atlas surfaces an exhausted-retry (or retry-unsafe) optimistic-lock
10
+ # conflict as an HTTP `409 Conflict` whose JSON body carries the
11
+ # discriminator `error: "stale_resource"`. This middleware keys on the
12
+ # **status + discriminator pair** and raises {AtlasRb::StaleResourceError},
13
+ # carrying the envelope's `resource_id` and `action` through so callers'
14
+ # failure logs are useful without the full response.
15
+ #
16
+ # It is intentionally narrow: any other status, or a 409 without the
17
+ # discriminator, passes through untouched so the caller still sees the
18
+ # response as a Mash (see {AtlasRb::StaleResourceError} for the rationale —
19
+ # atlas_rb stays a thin Faraday binding and translates only the one wire
20
+ # signal Cerberus jobs need to discriminate on).
21
+ class RaiseOnStaleResource < Faraday::Middleware
22
+ # @param env [Faraday::Env] the completed response environment.
23
+ # @raise [AtlasRb::StaleResourceError] on a 409 whose body carries
24
+ # `error: "stale_resource"`.
25
+ # @return [void]
26
+ def on_complete(env)
27
+ return unless env.status == 409
28
+
29
+ body = parse_json(env.body)
30
+ return unless body.is_a?(Hash) && body["error"] == "stale_resource"
31
+
32
+ raise AtlasRb::StaleResourceError.new(
33
+ body["message"] || "Atlas reported a stale-resource conflict",
34
+ resource_id: body["resource_id"],
35
+ action: body["action"]
36
+ )
37
+ end
38
+
39
+ private
40
+
41
+ def parse_json(body)
42
+ return body if body.is_a?(Hash)
43
+
44
+ JSON.parse(body.to_s)
45
+ rescue JSON::ParserError
46
+ nil
47
+ end
48
+ end
49
+ end
50
+ end
data/lib/atlas_rb/work.rb CHANGED
@@ -129,6 +129,43 @@ module AtlasRb
129
129
  find(result["id"], nuid: nuid, on_behalf_of: on_behalf_of)
130
130
  end
131
131
 
132
+ # Move a Work to a different parent Collection.
133
+ #
134
+ # Wraps `PATCH /works/<id>/parent` with a `parent_id` of the new
135
+ # Collection. This changes the Work's single **structural** home
136
+ # (`a_member_of`) — distinct from {.add_linked_member}, which adds an
137
+ # additional *linked* membership without moving the Work. Atlas
138
+ # re-parents the Work and synchronously updates its ancestry index; the
139
+ # structural rules (type, cycle, tombstone guards) are enforced
140
+ # server-side and surface as a `422`.
141
+ #
142
+ # **Note**: like {.create}, the destination here is a **Collection**, but
143
+ # the underlying request still uses the shared `parent_id` body key (not
144
+ # `collection_id`) — every re-parent endpoint posts `{ parent_id }`.
145
+ #
146
+ # @param id [String] the Work ID to move.
147
+ # @param new_collection_id [String] the destination Collection ID.
148
+ # @param nuid [String, nil] optional acting user's NUID, forwarded as the
149
+ # `User:` header. Required for cerberus-token requests; legacy bearer
150
+ # tokens still resolve without it.
151
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
152
+ # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
153
+ # omitted.
154
+ # @return [Hash] the updated `"work"` object, already unwrapped — the
155
+ # same shape {.find} returns, reflecting the new `a_member_of`.
156
+ # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
157
+ # conflict that exhausted its internal retry budget (HTTP 409 with
158
+ # `error: "stale_resource"`).
159
+ #
160
+ # @example
161
+ # AtlasRb::Work.reparent("w-789", "col-999")
162
+ def self.reparent(id, new_collection_id, nuid: nil, on_behalf_of: nil)
163
+ AtlasRb::Mash.new(JSON.parse(
164
+ connection({ parent_id: new_collection_id }, nuid, on_behalf_of: on_behalf_of)
165
+ .patch(ROUTE + id + '/parent')&.body
166
+ ))["work"]
167
+ end
168
+
132
169
  # Tombstone (withdraw) a Work.
133
170
  #
134
171
  # The Work remains in Atlas storage along with its FileSets and Blobs,
@@ -170,6 +207,9 @@ module AtlasRb
170
207
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
171
208
  # omitted.
172
209
  # @return [Faraday::Response] the raw response. Status `200` on success.
210
+ # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
211
+ # conflict that exhausted its internal retry budget (HTTP 409 with
212
+ # `error: "stale_resource"`).
173
213
  #
174
214
  # @example
175
215
  # AtlasRb::Work.complete("w-789")
@@ -243,6 +283,9 @@ module AtlasRb
243
283
  # `User:` header. Required for cerberus-token requests; legacy bearer
244
284
  # tokens still resolve without it.
245
285
  # @return [AtlasRb::Mash] the parsed JSON response.
286
+ # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
287
+ # conflict that exhausted its internal retry budget (HTTP 409 with
288
+ # `error: "stale_resource"`).
246
289
  #
247
290
  # @example
248
291
  # AtlasRb::Work.set_thumbnails(
@@ -276,6 +319,9 @@ module AtlasRb
276
319
  # `User:` header. Required for cerberus-token requests; legacy bearer
277
320
  # tokens still resolve without it.
278
321
  # @return [AtlasRb::Mash] the parsed JSON response.
322
+ # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
323
+ # conflict that exhausted its internal retry budget (HTTP 409 with
324
+ # `error: "stale_resource"`).
279
325
  #
280
326
  # @example
281
327
  # AtlasRb::Work.set_image_derivatives(
@@ -340,5 +386,101 @@ module AtlasRb
340
386
  ROUTE + id + '/mods' + (kind.present? ? ".#{kind}" : '')
341
387
  )&.body
342
388
  end
389
+
390
+ # List the Collections a Work is a *linked* member of.
391
+ #
392
+ # Wraps `GET /works/<id>/linked_members`. Linked membership is the DAG
393
+ # overlay — a Work has exactly one structural parent (`a_member_of`, set
394
+ # by {.create} / {.reparent}) but may additionally appear in any number
395
+ # of other Collections as a linked member (`a_linked_member_of`). This
396
+ # returns just those linked Collection noids; the structural parent is
397
+ # not included.
398
+ #
399
+ # @param id [String] the Work ID.
400
+ # @param nuid [String, nil] optional acting user's NUID, forwarded as the
401
+ # `User:` header. Required for cerberus-token requests; legacy bearer
402
+ # tokens still resolve without it.
403
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
404
+ # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
405
+ # omitted.
406
+ # @return [Array<String>] linked Collection noids (possibly empty). The
407
+ # shape mirrors {Collection.children} — a bare array of ids, not an
408
+ # envelope.
409
+ #
410
+ # @example
411
+ # AtlasRb::Work.linked_members("w-789")
412
+ # # => ["col-456", "col-457"]
413
+ def self.linked_members(id, nuid: nil, on_behalf_of: nil)
414
+ JSON.parse(
415
+ connection({}, nuid, on_behalf_of: on_behalf_of).get(ROUTE + id + '/linked_members')&.body
416
+ )
417
+ end
418
+
419
+ # Add a linked membership: surface a Work in an additional Collection.
420
+ #
421
+ # Wraps `POST /works/<id>/linked_members` with a `collection_id` body.
422
+ # This does **not** move the Work — its structural parent (`a_member_of`)
423
+ # is untouched; the Collection is added to `a_linked_member_of`. Atlas
424
+ # enforces two-sided authorization (edit on the Work *and* the target
425
+ # Collection) and the structural guards, surfacing failures as a `422`.
426
+ # Permissions are never changed by this call.
427
+ #
428
+ # @param work_id [String] the Work ID.
429
+ # @param collection_id [String] the Collection to link the Work into.
430
+ # @param nuid [String, nil] optional acting user's NUID, forwarded as the
431
+ # `User:` header. Required for cerberus-token requests; legacy bearer
432
+ # tokens still resolve without it.
433
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
434
+ # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
435
+ # omitted.
436
+ # @return [Array<String>] the Work's full set of linked Collection noids
437
+ # *after* the add — the affected sub-resource, so no follow-up
438
+ # {.linked_members} GET is needed.
439
+ # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
440
+ # conflict that exhausted its internal retry budget (HTTP 409 with
441
+ # `error: "stale_resource"`).
442
+ #
443
+ # @example
444
+ # AtlasRb::Work.add_linked_member("w-789", "col-456")
445
+ # # => ["col-456"]
446
+ def self.add_linked_member(work_id, collection_id, nuid: nil, on_behalf_of: nil)
447
+ JSON.parse(
448
+ connection({ collection_id: collection_id }, nuid, on_behalf_of: on_behalf_of)
449
+ .post(ROUTE + work_id + '/linked_members')&.body
450
+ )
451
+ end
452
+
453
+ # Remove a linked membership: drop a Work from an additional Collection.
454
+ #
455
+ # Wraps `DELETE /works/<id>/linked_members/<collection_id>` — the
456
+ # Collection is passed as a path segment, not a body. This removes the
457
+ # Collection from the Work's `a_linked_member_of`; the structural parent
458
+ # (`a_member_of`) is untouched. Atlas enforces the same two-sided
459
+ # authorization as {.add_linked_member}. Removing a link that does not
460
+ # exist is a server-side concern; this binding simply forwards the call.
461
+ #
462
+ # @param work_id [String] the Work ID.
463
+ # @param collection_id [String] the linked Collection to remove.
464
+ # @param nuid [String, nil] optional acting user's NUID, forwarded as the
465
+ # `User:` header. Required for cerberus-token requests; legacy bearer
466
+ # tokens still resolve without it.
467
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
468
+ # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
469
+ # omitted.
470
+ # @return [Array<String>] the Work's remaining linked Collection noids
471
+ # *after* the removal (possibly empty).
472
+ # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
473
+ # conflict that exhausted its internal retry budget (HTTP 409 with
474
+ # `error: "stale_resource"`).
475
+ #
476
+ # @example
477
+ # AtlasRb::Work.remove_linked_member("w-789", "col-456")
478
+ # # => []
479
+ def self.remove_linked_member(work_id, collection_id, nuid: nil, on_behalf_of: nil)
480
+ JSON.parse(
481
+ connection({}, nuid, on_behalf_of: on_behalf_of)
482
+ .delete(ROUTE + work_id + '/linked_members/' + collection_id)&.body
483
+ )
484
+ end
343
485
  end
344
486
  end
data/lib/atlas_rb.rb CHANGED
@@ -4,7 +4,9 @@ require "faraday"
4
4
  require "faraday/multipart"
5
5
  require "faraday/follow_redirects"
6
6
  require_relative "atlas_rb/version"
7
+ require_relative "atlas_rb/errors"
7
8
  require_relative "atlas_rb/configuration"
9
+ require_relative "atlas_rb/middleware/raise_on_stale_resource"
8
10
  require_relative "atlas_rb/faraday_helper"
9
11
  require_relative "atlas_rb/mash"
10
12
  require_relative "atlas_rb/authentication"
@@ -69,10 +71,8 @@ require_relative "atlas_rb/system/user"
69
71
  # AtlasRb::Blob.content(blob["id"]) { |chunk| f.write(chunk) }
70
72
  # end
71
73
  module AtlasRb
72
- # Generic error raised by future code paths; not currently used by any
73
- # resource class. Atlas errors today surface as raw `Faraday::Response`
74
- # objects or `JSON::ParserError`s on malformed bodies.
75
- class Error < StandardError; end
74
+ # The error hierarchy ({AtlasRb::Error}, {AtlasRb::StaleResourceError}) lives
75
+ # in `atlas_rb/errors.rb`, required above.
76
76
 
77
77
  # The gem-wide configuration instance. Lazily initialized — host
78
78
  # applications register defaults via {AtlasRb.configure}.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: atlas_rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cliff
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-27 00:00:00.000000000 Z
11
+ date: 2026-06-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -125,9 +125,11 @@ files:
125
125
  - lib/atlas_rb/community.rb
126
126
  - lib/atlas_rb/configuration.rb
127
127
  - lib/atlas_rb/delegate.rb
128
+ - lib/atlas_rb/errors.rb
128
129
  - lib/atlas_rb/faraday_helper.rb
129
130
  - lib/atlas_rb/file_set.rb
130
131
  - lib/atlas_rb/mash.rb
132
+ - lib/atlas_rb/middleware/raise_on_stale_resource.rb
131
133
  - lib/atlas_rb/resource.rb
132
134
  - lib/atlas_rb/system/user.rb
133
135
  - lib/atlas_rb/version.rb