atlas_rb 1.1.2 → 1.2.1

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: 195a3c85a8653e6835bb472b7cda45847e254057075694e37ecfa779efe2dd39
4
- data.tar.gz: ea58c157df06abcfa8d3f4c6b18fd00e5f87892a8b4231b526ac3e60a8e13510
3
+ metadata.gz: 30f433b53fc97128762eadbf01f12f975111c2b6e597469acc19831e3ef02bba
4
+ data.tar.gz: 3604a876f6061a2a4bf45953abae93faca9b7fdae27a9b264ec5e10526acaa0c
5
5
  SHA512:
6
- metadata.gz: 2782eeb3e29454728e68e85144ba5046dd86d9b2ff58eea1cfd704bf2ce03e70f81e977a5b9ad5ffe4091d69d99225746ed2076e875891f358308038983cbfe6
7
- data.tar.gz: 8dc180f51b17ffdb1feec818445573ec9c25eab3ee4e4245e9afb31f564a14dfbc93db40e3bd174a078e89f31bd57c240eb85b17f69828494ebaab3c0f23f9f4
6
+ metadata.gz: 62376e2b252c0269420ca89661bf7bb864b198f820076bd0c01210dc129adde111f218f2f33e7b3f88365a8b944cd5d2fda0758de050b9585c84ded5a1694309
7
+ data.tar.gz: d498f94bff1f2e13fcdacbca9ca05dce38bbcfe80209de0f3339f31b69aff8e47492980079096f2f141748845aab30e271ba54fa19639368c005a37d1cc0a9d7
data/.version CHANGED
@@ -1 +1 @@
1
- 1.1.2
1
+ 1.2.1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,81 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.1
4
+
5
+ ### Added — typed errors for re-parent / linked-member rejections
6
+
7
+ The re-parent and linked-member bindings no longer swallow Atlas's `4xx`
8
+ error envelope. Previously a `422`/`403` parsed fine but lacked the
9
+ success key (`"collection"` / `"work"` / `"community"`), so the binding
10
+ returned `nil` and discarded Atlas's machine-readable `error` / `message`
11
+ — callers could not tell an invalid move from a not-found from a
12
+ forbidden one.
13
+
14
+ - **`AtlasRb::ReparentError`** — raised on a `422` to a `.../parent` path
15
+ (`Collection`/`Community`/`Work.reparent`). Carries the envelope's
16
+ `error` discriminator as `#code` (`cycle`, `invalid_parent_type`,
17
+ `tombstoned_node`, `tombstoned_parent`, `parent_required`,
18
+ `parent_not_found`) plus `#resource_id` and `#message`.
19
+ - **`AtlasRb::LinkedMemberError`** — raised on a `422` to a
20
+ `.../linked_members` path (`Work.add_linked_member` /
21
+ `remove_linked_member`). Same shape (`#code`, `#resource_id`, `#message`).
22
+ - **`AtlasRb::ForbiddenError`** — raised on a `403` to either path.
23
+ Carries `#code`, `#action`, and `#subject` from the envelope.
24
+
25
+ All three subclass `AtlasRb::Error`. A new
26
+ `AtlasRb::Middleware::RaiseOnResourceError` (registered alongside
27
+ `RaiseOnStaleResource`) performs the translation, keyed on the request
28
+ **path + status** so it stays narrow: only the re-parent and linked-member
29
+ write paths are affected, and only `403`/`422` bodies carrying an `error`
30
+ discriminator. Other endpoints, other statuses, and the `tombstone`
31
+ endpoint's `code: "has_live_children"` body are untouched, and the `409`
32
+ optimistic-lock conflict still surfaces as `StaleResourceError`. Rescue is
33
+ opt-in — callers that don't discriminate see the success payload exactly as
34
+ before.
35
+
36
+ ## 1.2.0
37
+
38
+ ### Added — Tree/DAG foundation bindings
39
+
40
+ Thin Faraday mirrors for the two membership mutations Atlas shipped as
41
+ part of the DRS "Tree/DAG foundation" (re-parenting and linked members).
42
+ No client-side logic — the gem mirrors Atlas's wire and never queries
43
+ Solr.
44
+
45
+ - **`AtlasRb::Collection.reparent(id, new_parent_id, nuid: nil, on_behalf_of: nil)`**
46
+ - **`AtlasRb::Community.reparent(id, new_parent_id, nuid: nil, on_behalf_of: nil)`**
47
+ - **`AtlasRb::Work.reparent(id, new_collection_id, nuid: nil, on_behalf_of: nil)`**
48
+
49
+ Bind `PATCH /<type>/:id/parent` with a `{ parent_id }` body, moving a
50
+ resource to a new structural parent. Mirrors `create`'s single-parent-id
51
+ shape and returns the updated resource (same shape as `find`), reflecting
52
+ the new `a_member_of`. `Community.reparent` accepts `new_parent_id: nil`
53
+ to promote a Community to the top of the tree — the same way
54
+ `Community.create(nil)` makes a top-level Community; a `nil` destination
55
+ is rejected by Atlas for Works and Collections. Atlas enforces the
56
+ structural rules (type, cycle, tombstone) server-side and synchronously
57
+ cascades the ancestry index over descendants, surfacing violations as
58
+ `422`. The Work re-parent endpoint is included — Atlas shipped it (the
59
+ plan had flagged it as optional). All three endpoints use the shared
60
+ `parent_id` body key, including the Work one (not `collection_id`).
61
+
62
+ - **`AtlasRb::Work.linked_members(id, nuid: nil, on_behalf_of: nil)`** —
63
+ `GET /works/:id/linked_members`.
64
+ - **`AtlasRb::Work.add_linked_member(work_id, collection_id, nuid: nil, on_behalf_of: nil)`** —
65
+ `POST /works/:id/linked_members` with a `{ collection_id }` body.
66
+ - **`AtlasRb::Work.remove_linked_member(work_id, collection_id, nuid: nil, on_behalf_of: nil)`** —
67
+ `DELETE /works/:id/linked_members/:collection_id` (Collection as a path
68
+ segment).
69
+
70
+ The DAG overlay: a Work has one structural parent (`a_member_of`) but
71
+ may additionally be a *linked* member of any number of other Collections
72
+ (`a_linked_member_of`). These manage that overlay without moving the
73
+ Work. All three return the Work's current linked Collection noids as a
74
+ bare array (mirroring `Collection.children`); the two mutations return
75
+ the list *after* the change, so no follow-up GET is needed.
76
+
77
+ Cerberus consumes these from the re-parent and "add to collection" UI.
78
+
3
79
  ## 1.1.1
4
80
 
5
81
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- atlas_rb (1.1.2)
4
+ atlas_rb (1.2.1)
5
5
  faraday (~> 2.7)
6
6
  faraday-follow_redirects (~> 0.3.0)
7
7
  faraday-multipart (~> 1)
@@ -19,11 +19,11 @@ GEM
19
19
  faraday (>= 1, < 3)
20
20
  faraday-multipart (1.2.0)
21
21
  multipart-post (~> 2.0)
22
- faraday-net_http (3.4.3)
22
+ faraday-net_http (3.4.4)
23
23
  net-http (~> 0.5)
24
24
  hashie (5.1.0)
25
25
  logger
26
- json (2.19.7)
26
+ json (2.19.8)
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,73 @@ 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
+ When Atlas rejects a move, the binding raises a typed error carrying the
231
+ machine-readable `error` code from Atlas's envelope, rather than returning
232
+ `nil` — so callers can message the *specific* reason:
233
+
234
+ ```ruby
235
+ begin
236
+ AtlasRb::Collection.reparent("col-456", "c-999")
237
+ rescue AtlasRb::ReparentError => e
238
+ e.code # => "cycle" / "invalid_parent_type" / "tombstoned_parent" / …
239
+ e.message # => human-readable description from Atlas
240
+ rescue AtlasRb::ForbiddenError => e
241
+ e.code # => the authz failure (HTTP 403)
242
+ end
243
+ ```
244
+
245
+ `ReparentError` (HTTP 422, structural rules: `cycle`, `invalid_parent_type`,
246
+ `tombstoned_node`, `tombstoned_parent`, `parent_required`,
247
+ `parent_not_found`) and `ForbiddenError` (HTTP 403, authorization) both
248
+ subclass `AtlasRb::Error`. The `409` optimistic-lock conflict still surfaces
249
+ as `StaleResourceError`, unchanged. Rescue is opt-in — callers that don't
250
+ care keep the success payload as before.
251
+
252
+ ### Linked members (the DAG overlay)
253
+
254
+ A Work has exactly one structural parent (`a_member_of`, set by `create` /
255
+ `reparent`) but may additionally be a *linked* member of any number of
256
+ other Collections (`a_linked_member_of`). The linked-member bindings on
257
+ `Work` manage that overlay without ever moving the Work:
258
+
259
+ ```ruby
260
+ AtlasRb::Work.linked_members("w-789") # => ["col-456", "col-457"]
261
+ AtlasRb::Work.add_linked_member("w-789", "col-456") # => ["col-456"] (updated list)
262
+ AtlasRb::Work.remove_linked_member("w-789", "col-456") # => [] (updated list)
263
+ ```
264
+
265
+ All three return the Work's current linked Collection noids as a bare
266
+ array (mirroring `Collection.children`); the two mutations return the list
267
+ *after* the change, so no follow-up `linked_members` GET is needed.
268
+ Resolving those Collections' full contents is a Cerberus/Solr concern —
269
+ this gem never queries the index.
270
+
271
+ The two mutations raise the same way `reparent` does — `LinkedMemberError`
272
+ on a structural `422` (carrying the envelope's `error` code as `#code`) and
273
+ `ForbiddenError` on a `403` — instead of swallowing the envelope.
274
+
208
275
  ## End-to-end example
209
276
 
210
277
  JSON responses come back as `AtlasRb::Mash` (a `Hashie::Mash` subclass), so
@@ -65,6 +65,45 @@ 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
+ # @raise [AtlasRb::ReparentError] if Atlas rejects the move on structural
92
+ # grounds (HTTP 422 — `cycle`, `invalid_parent_type`, `tombstoned_node`,
93
+ # `tombstoned_parent`, `parent_required`, `parent_not_found`). The
94
+ # envelope's `error` code is exposed as `#code`.
95
+ # @raise [AtlasRb::ForbiddenError] if Atlas refuses the move on
96
+ # authorization grounds (HTTP 403).
97
+ #
98
+ # @example
99
+ # AtlasRb::Collection.reparent("col-456", "c-999")
100
+ def self.reparent(id, new_parent_id, nuid: nil, on_behalf_of: nil)
101
+ AtlasRb::Mash.new(JSON.parse(
102
+ connection({ parent_id: new_parent_id }, nuid, on_behalf_of: on_behalf_of)
103
+ .patch(ROUTE + id + '/parent')&.body
104
+ ))["collection"]
105
+ end
106
+
68
107
  # Tombstone (withdraw) a Collection.
69
108
  #
70
109
  # The Collection remains in Atlas storage but is marked as withdrawn:
@@ -69,6 +69,50 @@ 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
+ # @raise [AtlasRb::ReparentError] if Atlas rejects the move on structural
99
+ # grounds (HTTP 422 — `cycle`, `tombstoned_node`, `tombstoned_parent`,
100
+ # `parent_not_found`). The envelope's `error` code is exposed as `#code`.
101
+ # @raise [AtlasRb::ForbiddenError] if Atlas refuses the move on
102
+ # authorization grounds (HTTP 403).
103
+ #
104
+ # @example Move under another Community
105
+ # AtlasRb::Community.reparent("c-123", "c-999")
106
+ #
107
+ # @example Promote to a top-level Community
108
+ # AtlasRb::Community.reparent("c-123", nil)
109
+ def self.reparent(id, new_parent_id, nuid: nil, on_behalf_of: nil)
110
+ AtlasRb::Mash.new(JSON.parse(
111
+ connection({ parent_id: new_parent_id }, nuid, on_behalf_of: on_behalf_of)
112
+ .patch(ROUTE + id + '/parent')&.body
113
+ ))["community"]
114
+ end
115
+
72
116
  # Tombstone (withdraw) a Community.
73
117
  #
74
118
  # The Community remains in Atlas storage but is marked as withdrawn:
@@ -36,4 +36,98 @@ module AtlasRb
36
36
  @action = action
37
37
  end
38
38
  end
39
+
40
+ # Raised when Atlas rejects a re-parent (`PATCH /<type>/:id/parent`) with a
41
+ # structural `422` carrying a machine-readable `error` discriminator —
42
+ # `tombstoned_node`, `tombstoned_parent`, `parent_required`,
43
+ # `invalid_parent_type`, `cycle`, or `parent_not_found`.
44
+ #
45
+ # Mirrors {StaleResourceError}: a narrow translation of one wire signal
46
+ # callers need to discriminate on. Without it the binding's `["collection"]`
47
+ # / `["work"]` / `["community"]` unwrap silently returns `nil` on a 422,
48
+ # discarding Atlas's `error`/`message` and leaving the caller unable to tell
49
+ # an invalid move from a not-found from a forbidden one.
50
+ #
51
+ # Callers key on {#code} for specific messaging, falling back to {#message}:
52
+ #
53
+ # rescue AtlasRb::ReparentError => e
54
+ # flash.now[:alert] = t("reparent.errors.#{e.code}", default: e.message)
55
+ #
56
+ # @note Authorization failures surface as {ForbiddenError} (HTTP 403), not
57
+ # this — even on a re-parent path.
58
+ class ReparentError < Error
59
+ # @return [String, nil] the machine-readable error code from the envelope
60
+ # (e.g. `"cycle"`), suitable for keying an i18n map.
61
+ attr_reader :code
62
+
63
+ # @return [String, nil] the rejected resource's ID, from the envelope.
64
+ attr_reader :resource_id
65
+
66
+ # @param message [String] human-readable rejection description.
67
+ # @param code [String, nil] the envelope's `error` discriminator.
68
+ # @param resource_id [String, nil] the rejected resource's ID.
69
+ def initialize(message, code: nil, resource_id: nil)
70
+ super(message)
71
+ @code = code
72
+ @resource_id = resource_id
73
+ end
74
+ end
75
+
76
+ # Raised when Atlas rejects a linked-member write
77
+ # (`POST` / `DELETE /works/:id/linked_members`) with a `422` carrying a
78
+ # machine-readable `error` discriminator. The linked-member sibling of
79
+ # {ReparentError}; same shape, same rationale (the binding would otherwise
80
+ # discard the envelope on a non-2xx).
81
+ #
82
+ # rescue AtlasRb::LinkedMemberError => e
83
+ # flash.now[:alert] = t("linked_member.errors.#{e.code}", default: e.message)
84
+ #
85
+ # @note Authorization failures surface as {ForbiddenError} (HTTP 403).
86
+ class LinkedMemberError < Error
87
+ # @return [String, nil] the machine-readable error code from the envelope,
88
+ # suitable for keying an i18n map.
89
+ attr_reader :code
90
+
91
+ # @return [String, nil] the rejected resource's ID, from the envelope.
92
+ attr_reader :resource_id
93
+
94
+ # @param message [String] human-readable rejection description.
95
+ # @param code [String, nil] the envelope's `error` discriminator.
96
+ # @param resource_id [String, nil] the rejected resource's ID.
97
+ def initialize(message, code: nil, resource_id: nil)
98
+ super(message)
99
+ @code = code
100
+ @resource_id = resource_id
101
+ end
102
+ end
103
+
104
+ # Raised when Atlas refuses a re-parent or linked-member write with an
105
+ # HTTP `403`, whose envelope is `{ "error", "action", "subject" }`. Lets
106
+ # callers distinguish "you may not do this" from a structural rejection
107
+ # ({ReparentError} / {LinkedMemberError}) or a not-found.
108
+ #
109
+ # @note Scoped to the re-parent / linked-member write paths — `403`s on
110
+ # other endpoints still surface as raw responses for the caller's own
111
+ # rescue layer, unchanged.
112
+ class ForbiddenError < Error
113
+ # @return [String, nil] the envelope's `error` value.
114
+ attr_reader :code
115
+
116
+ # @return [String, nil] the action that was forbidden (e.g. `"reparent"`).
117
+ attr_reader :action
118
+
119
+ # @return [String, nil] the subject (resource) the action was forbidden on.
120
+ attr_reader :subject
121
+
122
+ # @param message [String] human-readable authorization-failure description.
123
+ # @param code [String, nil] the envelope's `error` value.
124
+ # @param action [String, nil] the forbidden action.
125
+ # @param subject [String, nil] the subject the action was forbidden on.
126
+ def initialize(message, code: nil, action: nil, subject: nil)
127
+ super(message)
128
+ @code = code
129
+ @action = action
130
+ @subject = subject
131
+ end
132
+ end
39
133
  end
@@ -60,6 +60,7 @@ module AtlasRb
60
60
  headers: headers
61
61
  ) do |f|
62
62
  f.use AtlasRb::Middleware::RaiseOnStaleResource
63
+ f.use AtlasRb::Middleware::RaiseOnResourceError
63
64
  f.response :follow_redirects
64
65
  f.adapter Faraday.default_adapter
65
66
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtlasRb
4
+ module Middleware
5
+ # Translates Atlas's structured re-parent / linked-member rejections into
6
+ # typed Ruby exceptions, so the resource bindings don't silently swallow
7
+ # the error envelope.
8
+ #
9
+ # The re-parent and linked-member bindings unwrap their success payload by
10
+ # a fixed key (`["collection"]` / `["work"]` / `["community"]`, or the
11
+ # bare linked-member array). On a `4xx` that key is absent, so the binding
12
+ # would return `nil` and discard Atlas's machine-readable `error` /
13
+ # `message`. This middleware keys on the **request path + status** and
14
+ # raises a typed error carrying the envelope through, parallel to
15
+ # {RaiseOnStaleResource}.
16
+ #
17
+ # It is intentionally narrow — it only fires on the re-parent
18
+ # (`.../parent`) and linked-member (`.../linked_members...`) write paths,
19
+ # and only on `403` / `422` bodies carrying an `error` discriminator.
20
+ # Everything else (other paths, other statuses, a `422` whose body uses a
21
+ # different discriminator such as `tombstone`'s `code: "has_live_children"`)
22
+ # passes through untouched, so atlas_rb stays a thin Faraday binding that
23
+ # translates only the wire signals callers genuinely need to discriminate.
24
+ #
25
+ # Mapping:
26
+ # - `403` (either path) → {AtlasRb::ForbiddenError} (`error`/`action`/`subject`)
27
+ # - `422` on `.../parent` → {AtlasRb::ReparentError} (`error`/`resource_id`)
28
+ # - `422` on `.../linked_members...` → {AtlasRb::LinkedMemberError}
29
+ class RaiseOnResourceError < Faraday::Middleware
30
+ # @param env [Faraday::Env] the completed response environment.
31
+ # @raise [AtlasRb::ForbiddenError] on a 403 to a re-parent / linked-member path.
32
+ # @raise [AtlasRb::ReparentError] on a 422 to a re-parent path.
33
+ # @raise [AtlasRb::LinkedMemberError] on a 422 to a linked-member path.
34
+ # @return [void]
35
+ def on_complete(env)
36
+ return unless env.status == 403 || env.status == 422
37
+
38
+ path = env.url&.path.to_s
39
+ reparent = path.end_with?("/parent")
40
+ linked = path.include?("/linked_members")
41
+ return unless reparent || linked
42
+
43
+ body = parse_json(env.body)
44
+ return unless body.is_a?(Hash) && body["error"]
45
+
46
+ if env.status == 403
47
+ raise AtlasRb::ForbiddenError.new(
48
+ body["message"] || "Atlas refused the request",
49
+ code: body["error"],
50
+ action: body["action"],
51
+ subject: body["subject"]
52
+ )
53
+ elsif reparent
54
+ raise AtlasRb::ReparentError.new(
55
+ body["message"] || "Atlas rejected the re-parent",
56
+ code: body["error"],
57
+ resource_id: body["resource_id"]
58
+ )
59
+ else
60
+ raise AtlasRb::LinkedMemberError.new(
61
+ body["message"] || "Atlas rejected the linked-member write",
62
+ code: body["error"],
63
+ resource_id: body["resource_id"]
64
+ )
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def parse_json(body)
71
+ return body if body.is_a?(Hash)
72
+
73
+ JSON.parse(body.to_s)
74
+ rescue JSON::ParserError
75
+ nil
76
+ end
77
+ end
78
+ end
79
+ end
data/lib/atlas_rb/work.rb CHANGED
@@ -129,6 +129,49 @@ 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
+ # @raise [AtlasRb::ReparentError] if Atlas rejects the move on structural
160
+ # grounds (HTTP 422 — `cycle`, `invalid_parent_type`, `tombstoned_node`,
161
+ # `tombstoned_parent`, `parent_required`, `parent_not_found`). The
162
+ # envelope's `error` code is exposed as `#code`.
163
+ # @raise [AtlasRb::ForbiddenError] if Atlas refuses the move on
164
+ # authorization grounds (HTTP 403).
165
+ #
166
+ # @example
167
+ # AtlasRb::Work.reparent("w-789", "col-999")
168
+ def self.reparent(id, new_collection_id, nuid: nil, on_behalf_of: nil)
169
+ AtlasRb::Mash.new(JSON.parse(
170
+ connection({ parent_id: new_collection_id }, nuid, on_behalf_of: on_behalf_of)
171
+ .patch(ROUTE + id + '/parent')&.body
172
+ ))["work"]
173
+ end
174
+
132
175
  # Tombstone (withdraw) a Work.
133
176
  #
134
177
  # The Work remains in Atlas storage along with its FileSets and Blobs,
@@ -349,5 +392,111 @@ module AtlasRb
349
392
  ROUTE + id + '/mods' + (kind.present? ? ".#{kind}" : '')
350
393
  )&.body
351
394
  end
395
+
396
+ # List the Collections a Work is a *linked* member of.
397
+ #
398
+ # Wraps `GET /works/<id>/linked_members`. Linked membership is the DAG
399
+ # overlay — a Work has exactly one structural parent (`a_member_of`, set
400
+ # by {.create} / {.reparent}) but may additionally appear in any number
401
+ # of other Collections as a linked member (`a_linked_member_of`). This
402
+ # returns just those linked Collection noids; the structural parent is
403
+ # not included.
404
+ #
405
+ # @param id [String] the Work ID.
406
+ # @param nuid [String, nil] optional acting user's NUID, forwarded as the
407
+ # `User:` header. Required for cerberus-token requests; legacy bearer
408
+ # tokens still resolve without it.
409
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
410
+ # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
411
+ # omitted.
412
+ # @return [Array<String>] linked Collection noids (possibly empty). The
413
+ # shape mirrors {Collection.children} — a bare array of ids, not an
414
+ # envelope.
415
+ #
416
+ # @example
417
+ # AtlasRb::Work.linked_members("w-789")
418
+ # # => ["col-456", "col-457"]
419
+ def self.linked_members(id, nuid: nil, on_behalf_of: nil)
420
+ JSON.parse(
421
+ connection({}, nuid, on_behalf_of: on_behalf_of).get(ROUTE + id + '/linked_members')&.body
422
+ )
423
+ end
424
+
425
+ # Add a linked membership: surface a Work in an additional Collection.
426
+ #
427
+ # Wraps `POST /works/<id>/linked_members` with a `collection_id` body.
428
+ # This does **not** move the Work — its structural parent (`a_member_of`)
429
+ # is untouched; the Collection is added to `a_linked_member_of`. Atlas
430
+ # enforces two-sided authorization (edit on the Work *and* the target
431
+ # Collection) and the structural guards, surfacing failures as a `422`.
432
+ # Permissions are never changed by this call.
433
+ #
434
+ # @param work_id [String] the Work ID.
435
+ # @param collection_id [String] the Collection to link the Work into.
436
+ # @param nuid [String, nil] optional acting user's NUID, forwarded as the
437
+ # `User:` header. Required for cerberus-token requests; legacy bearer
438
+ # tokens still resolve without it.
439
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
440
+ # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
441
+ # omitted.
442
+ # @return [Array<String>] the Work's full set of linked Collection noids
443
+ # *after* the add — the affected sub-resource, so no follow-up
444
+ # {.linked_members} GET is needed.
445
+ # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
446
+ # conflict that exhausted its internal retry budget (HTTP 409 with
447
+ # `error: "stale_resource"`).
448
+ # @raise [AtlasRb::LinkedMemberError] if Atlas rejects the link on
449
+ # structural grounds (HTTP 422). The envelope's `error` code is exposed
450
+ # as `#code`.
451
+ # @raise [AtlasRb::ForbiddenError] if Atlas refuses the link on
452
+ # authorization grounds (HTTP 403).
453
+ #
454
+ # @example
455
+ # AtlasRb::Work.add_linked_member("w-789", "col-456")
456
+ # # => ["col-456"]
457
+ def self.add_linked_member(work_id, collection_id, nuid: nil, on_behalf_of: nil)
458
+ JSON.parse(
459
+ connection({ collection_id: collection_id }, nuid, on_behalf_of: on_behalf_of)
460
+ .post(ROUTE + work_id + '/linked_members')&.body
461
+ )
462
+ end
463
+
464
+ # Remove a linked membership: drop a Work from an additional Collection.
465
+ #
466
+ # Wraps `DELETE /works/<id>/linked_members/<collection_id>` — the
467
+ # Collection is passed as a path segment, not a body. This removes the
468
+ # Collection from the Work's `a_linked_member_of`; the structural parent
469
+ # (`a_member_of`) is untouched. Atlas enforces the same two-sided
470
+ # authorization as {.add_linked_member}. Removing a link that does not
471
+ # exist is a server-side concern; this binding simply forwards the call.
472
+ #
473
+ # @param work_id [String] the Work ID.
474
+ # @param collection_id [String] the linked Collection to remove.
475
+ # @param nuid [String, nil] optional acting user's NUID, forwarded as the
476
+ # `User:` header. Required for cerberus-token requests; legacy bearer
477
+ # tokens still resolve without it.
478
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
479
+ # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
480
+ # omitted.
481
+ # @return [Array<String>] the Work's remaining linked Collection noids
482
+ # *after* the removal (possibly empty).
483
+ # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
484
+ # conflict that exhausted its internal retry budget (HTTP 409 with
485
+ # `error: "stale_resource"`).
486
+ # @raise [AtlasRb::LinkedMemberError] if Atlas rejects the removal on
487
+ # structural grounds (HTTP 422). The envelope's `error` code is exposed
488
+ # as `#code`.
489
+ # @raise [AtlasRb::ForbiddenError] if Atlas refuses the removal on
490
+ # authorization grounds (HTTP 403).
491
+ #
492
+ # @example
493
+ # AtlasRb::Work.remove_linked_member("w-789", "col-456")
494
+ # # => []
495
+ def self.remove_linked_member(work_id, collection_id, nuid: nil, on_behalf_of: nil)
496
+ JSON.parse(
497
+ connection({}, nuid, on_behalf_of: on_behalf_of)
498
+ .delete(ROUTE + work_id + '/linked_members/' + collection_id)&.body
499
+ )
500
+ end
352
501
  end
353
502
  end
data/lib/atlas_rb.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "atlas_rb/version"
7
7
  require_relative "atlas_rb/errors"
8
8
  require_relative "atlas_rb/configuration"
9
9
  require_relative "atlas_rb/middleware/raise_on_stale_resource"
10
+ require_relative "atlas_rb/middleware/raise_on_resource_error"
10
11
  require_relative "atlas_rb/faraday_helper"
11
12
  require_relative "atlas_rb/mash"
12
13
  require_relative "atlas_rb/authentication"
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.2
4
+ version: 1.2.1
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-29 00:00:00.000000000 Z
11
+ date: 2026-06-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -129,6 +129,7 @@ files:
129
129
  - lib/atlas_rb/faraday_helper.rb
130
130
  - lib/atlas_rb/file_set.rb
131
131
  - lib/atlas_rb/mash.rb
132
+ - lib/atlas_rb/middleware/raise_on_resource_error.rb
132
133
  - lib/atlas_rb/middleware/raise_on_stale_resource.rb
133
134
  - lib/atlas_rb/resource.rb
134
135
  - lib/atlas_rb/system/user.rb