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 +4 -4
- data/.version +1 -1
- data/CHANGELOG.md +76 -0
- data/Gemfile.lock +3 -3
- data/README.md +67 -0
- data/lib/atlas_rb/collection.rb +39 -0
- data/lib/atlas_rb/community.rb +44 -0
- data/lib/atlas_rb/errors.rb +94 -0
- data/lib/atlas_rb/faraday_helper.rb +1 -0
- data/lib/atlas_rb/middleware/raise_on_resource_error.rb +79 -0
- data/lib/atlas_rb/work.rb +149 -0
- data/lib/atlas_rb.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 30f433b53fc97128762eadbf01f12f975111c2b6e597469acc19831e3ef02bba
|
|
4
|
+
data.tar.gz: 3604a876f6061a2a4bf45953abae93faca9b7fdae27a9b264ec5e10526acaa0c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 62376e2b252c0269420ca89661bf7bb864b198f820076bd0c01210dc129adde111f218f2f33e7b3f88365a8b944cd5d2fda0758de050b9585c84ded5a1694309
|
|
7
|
+
data.tar.gz: d498f94bff1f2e13fcdacbca9ca05dce38bbcfe80209de0f3339f31b69aff8e47492980079096f2f141748845aab30e271ba54fa19639368c005a37d1cc0a9d7
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.1
|
|
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
|
|
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.
|
|
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.
|
|
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
|
data/lib/atlas_rb/collection.rb
CHANGED
|
@@ -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:
|
data/lib/atlas_rb/community.rb
CHANGED
|
@@ -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:
|
data/lib/atlas_rb/errors.rb
CHANGED
|
@@ -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
|
|
@@ -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
|
|
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-
|
|
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
|