atlas_rb 1.17.0 → 1.18.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: 3a418fc64c12474c618d8bcffbaf01f116f0bb7c80c999f4c3bd4514cf47c879
4
- data.tar.gz: ebfa9f0d7e85b884f6f96a16dd59861d01a0d03bbf6a25c94a0e92665add8d2d
3
+ metadata.gz: dfa719c8d880ec4ed326597fe0882537649b69b6e0e52a3df4123353c1019198
4
+ data.tar.gz: 1ffae14221914997c13572495b728dbb93cd02c81144cba4eb5ca33f64bb444a
5
5
  SHA512:
6
- metadata.gz: 1e2541b872b918215dd73155ae55fc8d841a7da7fce77f6b984dc82187148dce0a4698ee2b8aaa561b659d1fd253f2077f614cb8d592edaa54a10a343ea8664a
7
- data.tar.gz: c0bb9652d3a1cfe08485d6057f23db0e7c493fefe4bfd23be7796e825e1a0bc998b4b02e625c2475e87b247ffc49ffb229469c35adb3b770010bda4cb42c1220
6
+ metadata.gz: b4705dd193fec70bb702846a51aa08a902a772a76964873d89a11ef2963823e7b5217525a3fb2efed339596ce213f04d6aad3cd43bb8b9387c92ea246e1d4740
7
+ data.tar.gz: 3ab173992d0243e249f51dd385cb6b7507bd8478adac2abcb1077e49c04bc0dfcb2d4fc665b9e843e72b17337dd9cdf8e8709008078dc0909befd87ef5472ac1
data/.version CHANGED
@@ -1 +1 @@
1
- 1.17.0
1
+ 1.18.0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,69 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.18.0
4
+
5
+ ### Changed — the write surface is type-agnostic, and the typed writes are gone
6
+
7
+ Atlas moved every write that needs no type onto the `/resources/{id}`
8
+ sub-resource that already served its `GET`, so the gem follows. Seven methods
9
+ replace twenty-one:
10
+
11
+ | Was | Is |
12
+ |---|---|
13
+ | `Work.update` / `Collection.update` / `Community.update` | `Resource.put_mods` |
14
+ | `.metadata` ×3 | `Resource.set_permissions` |
15
+ | `.set_thumbnails` ×3 | `Resource.set_thumbnails` |
16
+ | `.reparent` ×3 | `Resource.reparent` |
17
+ | `.tombstone` ×3 | `Resource.tombstone` |
18
+ | `Admin::{Work,Collection,Community}.restore` | `Admin::Resource.restore` |
19
+ | `Admin::{Work,Collection,Community}.destroy` | `Admin::Resource.destroy` |
20
+
21
+ `AtlasRb::Admin::Work`, `::Collection` and `::Community` are **removed** —
22
+ purge and restore were all they held.
23
+
24
+ **Why not keep the typed names as delegators.** One URL now serves every type,
25
+ so `Work.set_permissions(collection_id)` would succeed: the class would name a
26
+ type it could not enforce. A method that cannot keep its own promise is worse
27
+ than no method. Note that the subclasses still *answer* the generic writes,
28
+ because they inherit them from `Resource` — that was already true of the
29
+ generic reads (`history`, `permissions`, `find_many`), and `Resource.find`
30
+ remains the call that reports a type.
31
+
32
+ **`update` and `metadata` are not carried forward under any name.** Neither
33
+ said which document it wrote: `update` was the MODS upload and `metadata` was
34
+ the ACL. `Resource.mods` and `Resource.permissions` were already taken by the
35
+ reads, so the writes take `put_mods` and `set_permissions` rather than
36
+ overloading a name by arity on the ACL surface.
37
+
38
+ ### Changed — a generic write returns the resource unwrapped
39
+
40
+ Atlas answers a write with the resource under its type key. A caller of a
41
+ type-agnostic write does not know that key, so the gem unwraps it: `put_mods`,
42
+ `set_permissions`, `set_thumbnails` and `reparent` return the resource itself.
43
+
44
+ That also settles an inconsistency the typed methods carried — `reparent`
45
+ unwrapped, `update` and `metadata` did not.
46
+
47
+ `tombstone`, `Admin::Resource.restore` and `Admin::Resource.destroy` still
48
+ return the raw `Faraday::Response`. Atlas answers a refused tombstone with
49
+ `422 has_live_children`, which is a legitimate answer the caller reads rather
50
+ than an error to raise on.
51
+
52
+ ### Changed — `Collection.set_featured` has its own path
53
+
54
+ `PATCH /collections/{id}` had a **third** payload shape beside MODS and the
55
+ ACL: the showcase `featured` flag. It is Collection-only, so it keeps its type
56
+ and moves to `PATCH /collections/{id}/featured`. The binding signature is
57
+ unchanged.
58
+
59
+ ### Kept per-type, on purpose
60
+
61
+ `create` (it must name what to create), the Work-only writes (`complete`,
62
+ `mark_incomplete`, `clear_incomplete`, `set_image_derivatives`,
63
+ `set_derivative_permissions`, `set_full_text`, the linked-member and
64
+ association pairs), `Collection.set_featured`, and every FileSet, Blob and
65
+ Compilation write. Each addresses something only its own type has.
66
+
3
67
  ## 1.17.0
4
68
 
5
69
  ### Fixed — `Resource.find` emitted a type string this namespace cannot resolve
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- atlas_rb (1.17.0)
4
+ atlas_rb (1.18.0)
5
5
  faraday (~> 2.7)
6
6
  faraday-follow_redirects (~> 0.3.0)
7
7
  faraday-multipart (~> 1)
data/README.md CHANGED
@@ -277,18 +277,18 @@ radius and the kind of authentication they need:
277
277
 
278
278
  | Namespace | What it does | Auth | Friction |
279
279
  |----------------------|------------------------------------------------------------------------------------|-----------------------------------------------------|---------------------------------------|
280
- | `AtlasRb::*` | Regular CRUD (find / list / create / update / tombstone / metadata, etc.) | Relay-signing (signed assertion, `sub` = acting NUID) | None — these are the daily-use paths. |
281
- | `AtlasRb::Admin::*` | Hard delete (`destroy`) and un-tombstone (`restore`) for Work / Collection / Community. | Same as regular — a real operator is acting. | `destroy` requires `confirm: :i_understand`. |
280
+ | `AtlasRb::*` | Regular CRUD typed reads and `create`, plus every type-agnostic write on `Resource`. | Relay-signing (signed assertion, `sub` = acting NUID) | None — these are the daily-use paths. |
281
+ | `AtlasRb::Admin::*` | Hard delete (`destroy`) and un-tombstone (`restore`), on `Admin::Resource`. | Same as regular — a real operator is acting. | `destroy` requires `confirm: :i_understand`. |
282
282
  | `AtlasRb::System::*` | System-context provisioning (currently just SSO user find-or-create). | System token (`Rails.application.credentials.atlas_system_token`) + `User: NUID 000000000`. | The namespace itself is the marker — there is no way to call these as a non-system principal. |
283
283
 
284
284
  ```ruby
285
285
  # Regular daily use — picks up Current.nuid via the configured default:
286
- AtlasRb::Work.find("w-789")
287
- AtlasRb::Work.tombstone("w-789") # withdrawal (reversible)
286
+ AtlasRb::Work.find("w-789") # typed read
287
+ AtlasRb::Resource.tombstone("w-789") # withdrawal (reversible), any type
288
288
 
289
289
  # Operator-only, with a friction marker:
290
- AtlasRb::Admin::Work.destroy("w-789", confirm: :i_understand)
291
- AtlasRb::Admin::Work.restore("w-789")
290
+ AtlasRb::Admin::Resource.destroy("w-789", confirm: :i_understand)
291
+ AtlasRb::Admin::Resource.restore("w-789")
292
292
 
293
293
  # System-only — authenticates as Atlas's :system fixture:
294
294
  AtlasRb::System::User.find_or_create(
@@ -531,6 +531,59 @@ The two mutations raise the same way `reparent` does — `LinkedMemberError`
531
531
  on a structural `422` (carrying the envelope's `error` code as `#code`) and
532
532
  `ForbiddenError` on a `403` — instead of swallowing the envelope.
533
533
 
534
+ ### Writing without knowing the type
535
+
536
+ Atlas serves the reads as sub-resources of `/resources/{id}` and serves the
537
+ writes as verbs on those same paths, so a caller holding a NOID and no type can
538
+ write. The whole surface:
539
+
540
+ | Call | Endpoint |
541
+ |---|---|
542
+ | `Resource.put_mods(id, xml_path, origin:)` | `PUT /resources/{id}/mods` |
543
+ | `Resource.set_permissions(id, acl)` | `PATCH /resources/{id}/permissions` |
544
+ | `Resource.set_thumbnails(id, thumbnail:, thumbnail_2x:, preview:)` | `PATCH /resources/{id}/thumbnails` |
545
+ | `Resource.reparent(id, parent_id)` | `PATCH /resources/{id}/parent` |
546
+ | `Resource.tombstone(id)` | `POST /resources/{id}/tombstone` |
547
+ | `Admin::Resource.restore(id)` | `POST /resources/{id}/restore` |
548
+ | `Admin::Resource.destroy(id, confirm: :i_understand)` | `DELETE /resources/{id}` |
549
+
550
+ ```ruby
551
+ AtlasRb::Resource.put_mods("xsj3xmz", "/tmp/work.xml", origin: "xml_editor")
552
+ AtlasRb::Resource.set_permissions("xsj3xmz", { "read" => ["public"] })
553
+ ```
554
+
555
+ **There are no typed counterparts.** One URL serves every type, so a typed
556
+ write would name a type it could not enforce — `Work.set_permissions` given a
557
+ Collection id would succeed. The subclasses still *answer* these methods,
558
+ because they inherit them from `Resource`, but calling one on a subclass is the
559
+ same call and checks nothing. That was already true of the generic reads;
560
+ `Resource.find` is what reports a type.
561
+
562
+ **The ACL merges per key.** A key you omit keeps its stored value, and a key
563
+ sent explicitly empty is cleared. So changing one slot no longer means reading
564
+ the whole envelope and writing it back to avoid erasing a field you never meant
565
+ to touch.
566
+
567
+ ```ruby
568
+ # publishes, and leaves the embargo, edit groups and depositor alone
569
+ AtlasRb::Resource.set_permissions(id, { "read" => ["public"] })
570
+ ```
571
+
572
+ **MODS is a PUT because the caller assembles the whole document** — descriptive
573
+ merge logic lives in the client, not in Atlas. A type that holds no MODS
574
+ answers `404`, exactly as the `GET` on that path does; the gem does not
575
+ pre-check, so Atlas stays the one enforcer.
576
+
577
+ A write returns the resource **unwrapped** from its type key, since the caller
578
+ of a type-agnostic write has no key to index by. `tombstone`, `restore` and
579
+ `destroy` return the raw `Faraday::Response`: Atlas answers a refused tombstone
580
+ with `422 has_live_children`, which is an answer to read rather than an error
581
+ to raise on.
582
+
583
+ Writes that address something only one type has stay typed — `create`, the
584
+ Work-only lifecycle and derivative writes, `Collection.set_featured`, and the
585
+ FileSet, Blob and Compilation writes.
586
+
534
587
  ### Resolving one id of unknown type (`Resource.find` and `Resource.class_for`)
535
588
 
536
589
  When a NOID arrives as runtime data and you do not know its type, resolve
@@ -637,16 +690,16 @@ written to prevent:
637
690
 
638
691
  ```ruby
639
692
  begin
640
- AtlasRb::Work.update("doesnotexist", "/tmp/mods.xml")
693
+ AtlasRb::Resource.put_mods("doesnotexist", "/tmp/mods.xml")
641
694
  rescue AtlasRb::NotFoundError => e
642
695
  e.status # => 404
643
- e.message # => "PATCH /works/doesnotexist → 404 (no such resource)"
696
+ e.message # => "PUT /resources/doesnotexist/mods → 404 (no such resource)"
644
697
  end
645
698
  ```
646
699
 
647
- This covers every `create` / `update` / `metadata` / `parent` / `rollback` and
648
- their siblings on `Work`, `Collection`, `Community`, `Blob`, `FileSet`,
649
- `Compilation` and `Person`. Any other non-2xx a write gets raises
700
+ This covers every write: `create` and the type-specific writes on `Work`,
701
+ `Collection`, `Blob`, `FileSet`, `Compilation` and `Person`, and the
702
+ type-agnostic ones on `Resource`. Any other non-2xx a write gets raises
650
703
  `AtlasRb::ResourceError`, which `NotFoundError` subclasses — so a caller that
651
704
  only wants "the write failed" rescues the parent.
652
705
 
@@ -668,7 +721,7 @@ Refused writes raise on **every** binding and **every** path:
668
721
 
669
722
  ```ruby
670
723
  begin
671
- AtlasRb::Work.update("w-789", metadata)
724
+ AtlasRb::Resource.set_permissions("w-789", { "read" => ["public"] })
672
725
  rescue AtlasRb::ReadOnlyModeError => e
673
726
  e.retry_after # => 900 (seconds, from Atlas's Retry-After header)
674
727
  e.message # => "Atlas is in maintenance mode; writes are refused"
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtlasRb
4
+ module Admin
5
+ # Destructive lifecycle operations that need no type, against Atlas's
6
+ # generic `/resources/{id}` surface.
7
+ #
8
+ # The typed {AtlasRb::Admin::Work}, {AtlasRb::Admin::Collection} and
9
+ # {AtlasRb::Admin::Community} delegate here. See {AtlasRb::Admin} for why
10
+ # the namespace exists and what `confirm: :i_understand` is for — both
11
+ # apply unchanged, which is why purge did not move onto
12
+ # {AtlasRb::Resource} beside the other generic writes.
13
+ class Resource
14
+ extend AtlasRb::FaradayHelper
15
+
16
+ # Atlas REST endpoint prefix.
17
+ # @api private
18
+ ROUTE = "/resources/"
19
+
20
+ # Hard-delete a resource — a purge, not a withdrawal.
21
+ #
22
+ # Removes the resource's metadata, cascades into its members, and removes
23
+ # the OCFL objects holding the preserved bytes: every retained revision,
24
+ # not only the current one. Nothing survives but the audit row, which
25
+ # records the NOIDs it removed.
26
+ #
27
+ # Unrecoverable — prefer {AtlasRb::Resource.tombstone} for the
28
+ # user-visible withdrawal path, which keeps everything and can be
29
+ # reversed. Admin-only.
30
+ #
31
+ # @param id [String] the resource's NOID.
32
+ # @param confirm [Symbol] must be `:i_understand`.
33
+ # @param nuid [String, nil] optional acting user's NUID.
34
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
35
+ # header.
36
+ # @return [Faraday::Response] the raw delete response.
37
+ # @raise [ArgumentError] if `confirm:` is missing or not the sentinel
38
+ # value. Raised before any request goes out.
39
+ #
40
+ # @example
41
+ # AtlasRb::Admin::Resource.destroy("xsj3xmz", confirm: :i_understand)
42
+ def self.destroy(id, confirm:, nuid: nil, on_behalf_of: nil)
43
+ unless confirm == :i_understand
44
+ raise ArgumentError,
45
+ "AtlasRb::Admin::Resource.destroy requires confirm: :i_understand"
46
+ end
47
+ connection({}, nuid, on_behalf_of: on_behalf_of).delete(ROUTE + id)
48
+ end
49
+
50
+ # Restore a previously-tombstoned resource.
51
+ #
52
+ # Reverses a withdrawal: search and show pages stop returning a withdrawn
53
+ # stub. No `confirm:` marker — restoring is itself reversible, by
54
+ # tombstoning again.
55
+ #
56
+ # @param id [String] the resource's NOID.
57
+ # @param nuid [String, nil] optional acting user's NUID.
58
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
59
+ # header.
60
+ # @return [Faraday::Response] the raw response.
61
+ #
62
+ # @example
63
+ # AtlasRb::Admin::Resource.restore("xsj3xmz")
64
+ def self.restore(id, nuid: nil, on_behalf_of: nil)
65
+ connection({}, nuid, on_behalf_of: on_behalf_of).post(ROUTE + id + '/restore')
66
+ end
67
+ end
68
+ end
69
+ end
@@ -10,10 +10,11 @@ module AtlasRb
10
10
  #
11
11
  # ## Why a separate namespace
12
12
  #
13
- # The class itself is the marker: `AtlasRb::Admin::Work.destroy(...)`
14
- # is structurally distinct from `AtlasRb::Work.update(...)`. Mass-edits
15
- # and code-search across a consumer codebase can quickly find every
16
- # destructive call site by grepping `AtlasRb::Admin::`.
13
+ # The namespace itself is the marker: `AtlasRb::Admin::Resource.destroy(...)`
14
+ # is structurally distinct from every other write. Mass-edits and code-search
15
+ # across a consumer codebase can find every destructive call site by grepping
16
+ # `AtlasRb::Admin::`. That is also why purge did not move onto
17
+ # {AtlasRb::Resource} beside the other type-agnostic writes.
17
18
  #
18
19
  # ## `confirm: :i_understand`
19
20
  #
@@ -84,7 +84,9 @@ module AtlasRb
84
84
  ))["collection"]
85
85
  return result if xml_path.to_s.empty?
86
86
 
87
- update(result["id"], xml_path, nuid: nuid, on_behalf_of: on_behalf_of)
87
+ # The MODS seed is a second call: create takes the parent and the
88
+ # provenance slots, and the document goes through the write that owns it.
89
+ AtlasRb::Resource.put_mods(result["id"], xml_path, nuid: nuid, on_behalf_of: on_behalf_of)
88
90
  find(result["id"], nuid: nuid, on_behalf_of: on_behalf_of)
89
91
  end
90
92
 
@@ -109,75 +111,11 @@ module AtlasRb
109
111
  # AtlasRb::Collection.set_featured("col-456", true)
110
112
  def self.set_featured(id, featured, nuid: nil, on_behalf_of: nil)
111
113
  AtlasRb::Mash.new(write_resource(
112
- connection({ featured: featured }, nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id)
114
+ connection({ featured: featured }, nuid, on_behalf_of: on_behalf_of)
115
+ .patch(ROUTE + id + '/featured')
113
116
  ))["collection"]
114
117
  end
115
118
 
116
- # Move a Collection to a different parent (Community or Collection).
117
- #
118
- # Wraps `PATCH /collections/<id>/parent` with a `parent_id` of the new
119
- # parent. Atlas re-parents the Collection and synchronously cascades
120
- # the ancestry index over its Works; the structural rules (type, cycle,
121
- # tombstone guards) are enforced server-side and surface as a `422`.
122
- #
123
- # Mirrors {.create}'s "single parent id" shape — same kwarg threading,
124
- # the only difference is the verb and that the Collection already exists.
125
- #
126
- # @param id [String] the Collection ID to move.
127
- # @param new_parent_id [String] the destination Community or Collection ID.
128
- # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
129
- # path it is signed into the assertion `sub`; on the BYO-JWT (`ATLAS_JWT`)
130
- # path it is ignored (identity lives in the token).
131
- # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
132
- # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
133
- # omitted.
134
- # @return [Hash] the updated `"collection"` object, already unwrapped —
135
- # the same shape {.find} returns, reflecting the new `a_member_of`.
136
- # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
137
- # conflict that exhausted its internal retry budget (HTTP 409 with
138
- # `error: "stale_resource"`).
139
- # @raise [AtlasRb::ReparentError] if Atlas rejects the move on structural
140
- # grounds (HTTP 422 — `cycle`, `invalid_parent_type`, `tombstoned_node`,
141
- # `tombstoned_parent`, `parent_required`, `parent_not_found`). The
142
- # envelope's `error` code is exposed as `#code`.
143
- # @raise [AtlasRb::ForbiddenError] if Atlas refuses the move on
144
- # authorization grounds (HTTP 403).
145
- # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
146
- # resource, so the write did not happen.
147
- # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
148
- # and body.
149
- #
150
- # @example
151
- # AtlasRb::Collection.reparent("col-456", "c-999")
152
- def self.reparent(id, new_parent_id, nuid: nil, on_behalf_of: nil)
153
- AtlasRb::Mash.new(write_resource(
154
- connection({ parent_id: new_parent_id }, nuid, on_behalf_of: on_behalf_of)
155
- .patch(ROUTE + id + '/parent')
156
- ))["collection"]
157
- end
158
-
159
- # Tombstone (withdraw) a Collection.
160
- #
161
- # The Collection remains in Atlas storage but is marked as withdrawn:
162
- # search and show pages return a withdrawn stub for every user. Atlas
163
- # rejects the request with `422 has_live_children` if the Collection
164
- # still has live (non-tombstoned) Works.
165
- #
166
- # @param id [String] the Collection ID.
167
- # @param nuid [String] the acting user's NUID, stamped on the resource
168
- # as `tombstoned_by` for audit purposes.
169
- # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
170
- # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
171
- # omitted.
172
- # @return [Faraday::Response] the raw response. `200`/`204` on success;
173
- # `422` with `{"code":"has_live_children"}` if the Collection is not empty.
174
- #
175
- # @example
176
- # AtlasRb::Collection.tombstone("col-456", nuid: "000000002")
177
- def self.tombstone(id, nuid: nil, on_behalf_of: nil)
178
- connection({}, nuid, on_behalf_of: on_behalf_of).post(ROUTE + id + '/tombstone')
179
- end
180
-
181
119
  # List the Works in a Collection.
182
120
  #
183
121
  # The endpoint returns just the noids; resolve each through
@@ -210,104 +148,6 @@ module AtlasRb
210
148
  read_body(connection({}, nuid, on_behalf_of: on_behalf_of).get(ROUTE + id + '/children'))
211
149
  end
212
150
 
213
- # Replace a Collection's metadata by uploading a MODS XML document.
214
- #
215
- # @param id [String] the Collection ID.
216
- # @param xml_path [String] path to a MODS XML file on disk.
217
- # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
218
- # path it is signed into the assertion `sub`; on the BYO-JWT (`ATLAS_JWT`)
219
- # path it is ignored (identity lives in the token).
220
- # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
221
- # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
222
- # omitted.
223
- # @param origin [String, nil] free-text tag naming the surface that made this
224
- # edit (e.g. `"metadata_form"`, `"xml_editor"`). Atlas records it verbatim
225
- # on the audit event; omit it and the event carries no origin.
226
- # @return [Hash] the parsed JSON response from the patch.
227
- # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
228
- # resource, so the write did not happen.
229
- # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
230
- # and body.
231
- #
232
- # @example
233
- # AtlasRb::Collection.update("col-456", "/tmp/collection-mods.xml")
234
- #
235
- # @example Recording which editing surface made the change
236
- # AtlasRb::Collection.update("col-456", "/tmp/collection-mods.xml", origin: "xml_editor")
237
- def self.update(id, xml_path, nuid: nil, on_behalf_of: nil, origin: nil)
238
- AtlasRb::Mash.new(write_resource(
239
- multipart(nuid, on_behalf_of: on_behalf_of)
240
- .patch(ROUTE + id, mods_upload_payload(xml_path, origin))
241
- ))
242
- end
243
-
244
- # Patch individual descriptive-metadata fields without uploading a
245
- # full MODS document.
246
- #
247
- # Scoped to user-authored descriptive metadata only. Programmatic
248
- # writes of machine-set Delegate URIs (thumbnails) have their own
249
- # purpose-specific endpoint — see {.set_thumbnails}.
250
- #
251
- # @param id [String] the Collection ID.
252
- # @param values [Hash] field-level metadata updates.
253
- # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
254
- # path it is signed into the assertion `sub`; on the BYO-JWT (`ATLAS_JWT`)
255
- # path it is ignored (identity lives in the token).
256
- # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
257
- # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
258
- # omitted.
259
- # @return [Hash] the parsed JSON response.
260
- # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
261
- # resource, so the write did not happen.
262
- # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
263
- # and body.
264
- #
265
- # @example
266
- # AtlasRb::Collection.metadata("col-456", title: "Renamed Collection")
267
- def self.metadata(id, values, nuid: nil, on_behalf_of: nil)
268
- AtlasRb::Mash.new(write_resource(
269
- connection({ metadata: values }, nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id)
270
- ))
271
- end
272
-
273
- # Attach the three thumbnail/preview Delegate URIs to a Collection.
274
- #
275
- # Collection-level mirror of {Work.set_thumbnails}. Atlas dispatches
276
- # each non-blank URI to its matching Delegate role
277
- # (`thumbnail_image` / `thumbnail_image_2x` / `preview_image`) via
278
- # `DelegateUpdater`. Missing keys are left untouched.
279
- #
280
- # @param id [String] the Collection ID.
281
- # @param thumbnail [String, nil] IIIF URI for the ~85² thumbnail.
282
- # @param thumbnail_2x [String, nil] IIIF URI for the ~170² 2x thumbnail.
283
- # @param preview [String, nil] IIIF URI for the ~500w preview image.
284
- # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
285
- # path it is signed into the assertion `sub`; on the BYO-JWT (`ATLAS_JWT`)
286
- # path it is ignored (identity lives in the token).
287
- # @return [AtlasRb::Mash] the parsed JSON response.
288
- # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
289
- # conflict that exhausted its internal retry budget (HTTP 409 with
290
- # `error: "stale_resource"`).
291
- # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
292
- # resource, so the write did not happen.
293
- # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
294
- # and body.
295
- #
296
- # @example
297
- # AtlasRb::Collection.set_thumbnails(
298
- # "col-456",
299
- # thumbnail: "https://iiif.example.edu/iiif/3/c.jp2/full/!85,85/0/default.jpg",
300
- # thumbnail_2x: "https://iiif.example.edu/iiif/3/c.jp2/full/!170,170/0/default.jpg",
301
- # preview: "https://iiif.example.edu/iiif/3/c.jp2/full/500,/0/default.jpg"
302
- # )
303
- def self.set_thumbnails(id, thumbnail: nil, thumbnail_2x: nil, preview: nil, nuid: nil, on_behalf_of: nil)
304
- body = { thumbnail: thumbnail, thumbnail_2x: thumbnail_2x, preview: preview }.compact
305
- AtlasRb::Mash.new(write_resource(
306
- connection({}, nuid, on_behalf_of: on_behalf_of)
307
- .patch(ROUTE + id + '/thumbnails', JSON.dump(body))
308
- ))
309
- end
310
-
311
151
  # Fetch the Collection's MODS representation in the requested format.
312
152
  #
313
153
  # @param id [String] the Collection ID.
@@ -82,80 +82,12 @@ module AtlasRb
82
82
  ))["community"]
83
83
  return result if xml_path.to_s.empty?
84
84
 
85
- update(result["id"], xml_path, nuid: nuid, on_behalf_of: on_behalf_of)
85
+ # The MODS seed is a second call: create takes the parent and the
86
+ # provenance slots, and the document goes through the write that owns it.
87
+ AtlasRb::Resource.put_mods(result["id"], xml_path, nuid: nuid, on_behalf_of: on_behalf_of)
86
88
  find(result["id"], nuid: nuid, on_behalf_of: on_behalf_of)
87
89
  end
88
90
 
89
- # Move a Community to a different parent Community — or to the top of the
90
- # tree.
91
- #
92
- # Wraps `PATCH /communities/<id>/parent` with a `parent_id` of the new
93
- # parent Community. Pass `new_parent_id = nil` to promote the Community to
94
- # a top-level node (no parent) — mirroring how {.create} treats a `nil`
95
- # `id`; the gem omits the blank param and Atlas reads it as "move to top".
96
- # Atlas re-parents the Community and synchronously cascades the ancestry
97
- # index over its descendant Collections and Works; the structural rules
98
- # (cycle, tombstone guards) are enforced server-side and surface as a
99
- # `422`.
100
- #
101
- # @param id [String] the Community ID to move.
102
- # @param new_parent_id [String, nil] the destination Community ID, or
103
- # `nil` to move the Community to the top of the tree.
104
- # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
105
- # path it is signed into the assertion `sub`; on the BYO-JWT (`ATLAS_JWT`)
106
- # path it is ignored (identity lives in the token).
107
- # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
108
- # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
109
- # omitted.
110
- # @return [Hash] the updated `"community"` object, already unwrapped —
111
- # the same shape {.find} returns, reflecting the new `a_member_of`.
112
- # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
113
- # conflict that exhausted its internal retry budget (HTTP 409 with
114
- # `error: "stale_resource"`).
115
- # @raise [AtlasRb::ReparentError] if Atlas rejects the move on structural
116
- # grounds (HTTP 422 — `cycle`, `tombstoned_node`, `tombstoned_parent`,
117
- # `parent_not_found`). The envelope's `error` code is exposed as `#code`.
118
- # @raise [AtlasRb::ForbiddenError] if Atlas refuses the move on
119
- # authorization grounds (HTTP 403).
120
- # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
121
- # resource, so the write did not happen.
122
- # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
123
- # and body.
124
- #
125
- # @example Move under another Community
126
- # AtlasRb::Community.reparent("c-123", "c-999")
127
- #
128
- # @example Promote to a top-level Community
129
- # AtlasRb::Community.reparent("c-123", nil)
130
- def self.reparent(id, new_parent_id, nuid: nil, on_behalf_of: nil)
131
- AtlasRb::Mash.new(write_resource(
132
- connection({ parent_id: new_parent_id }, nuid, on_behalf_of: on_behalf_of)
133
- .patch(ROUTE + id + '/parent')
134
- ))["community"]
135
- end
136
-
137
- # Tombstone (withdraw) a Community.
138
- #
139
- # The Community remains in Atlas storage but is marked as withdrawn:
140
- # search and show pages return a withdrawn stub for every user. Atlas
141
- # rejects the request with `422 has_live_children` if the Community
142
- # still has live (non-tombstoned) members.
143
- #
144
- # @param id [String] the Community ID.
145
- # @param nuid [String] the acting user's NUID, stamped on the resource
146
- # as `tombstoned_by` for audit purposes.
147
- # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
148
- # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
149
- # omitted.
150
- # @return [Faraday::Response] the raw response. `200`/`204` on success;
151
- # `422` with `{"code":"has_live_children"}` if the Community is not empty.
152
- #
153
- # @example
154
- # AtlasRb::Community.tombstone("c-123", nuid: "000000002")
155
- def self.tombstone(id, nuid: nil, on_behalf_of: nil)
156
- connection({}, nuid, on_behalf_of: on_behalf_of).post(ROUTE + id + '/tombstone')
157
- end
158
-
159
91
  # List the immediate children (sub-Communities and Collections) of a Community.
160
92
  #
161
93
  # The endpoint returns just the noids; resolve each through
@@ -189,105 +121,6 @@ module AtlasRb
189
121
  read_body(connection({}, nuid, on_behalf_of: on_behalf_of).get(ROUTE + id + '/children'))
190
122
  end
191
123
 
192
- # Replace a Community's metadata by uploading a MODS XML document.
193
- #
194
- # @param id [String] the Community ID.
195
- # @param xml_path [String] path to a MODS XML file on disk.
196
- # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
197
- # path it is signed into the assertion `sub`; on the BYO-JWT (`ATLAS_JWT`)
198
- # path it is ignored (identity lives in the token).
199
- # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
200
- # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
201
- # omitted.
202
- # @param origin [String, nil] free-text tag naming the surface that made this
203
- # edit (e.g. `"metadata_form"`, `"xml_editor"`). Atlas records it verbatim
204
- # on the audit event; omit it and the event carries no origin.
205
- # @return [Hash] the parsed JSON response from the patch.
206
- # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
207
- # resource, so the write did not happen.
208
- # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
209
- # and body.
210
- #
211
- # @example
212
- # AtlasRb::Community.update("c-123", "/tmp/community-mods.xml")
213
- #
214
- # @example Recording which editing surface made the change
215
- # AtlasRb::Community.update("c-123", "/tmp/community-mods.xml", origin: "xml_editor")
216
- def self.update(id, xml_path, nuid: nil, on_behalf_of: nil, origin: nil)
217
- AtlasRb::Mash.new(write_resource(
218
- multipart(nuid, on_behalf_of: on_behalf_of)
219
- .patch(ROUTE + id, mods_upload_payload(xml_path, origin))
220
- ))
221
- end
222
-
223
- # Patch individual descriptive-metadata fields without uploading a
224
- # full MODS document.
225
- #
226
- # Scoped to user-authored descriptive metadata only. Programmatic
227
- # writes of machine-set Delegate URIs (thumbnails) have their own
228
- # purpose-specific endpoint — see {.set_thumbnails}.
229
- #
230
- # @param id [String] the Community ID.
231
- # @param values [Hash] field-level metadata updates (shape determined by
232
- # the Atlas server, typically a mapping from MODS field name to value).
233
- # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
234
- # path it is signed into the assertion `sub`; on the BYO-JWT (`ATLAS_JWT`)
235
- # path it is ignored (identity lives in the token).
236
- # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
237
- # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
238
- # omitted.
239
- # @return [Hash] the parsed JSON response.
240
- # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
241
- # resource, so the write did not happen.
242
- # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
243
- # and body.
244
- #
245
- # @example
246
- # AtlasRb::Community.metadata("c-123", title: "New Name")
247
- def self.metadata(id, values, nuid: nil, on_behalf_of: nil)
248
- AtlasRb::Mash.new(write_resource(
249
- connection({ metadata: values }, nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id)
250
- ))
251
- end
252
-
253
- # Attach the three thumbnail/preview Delegate URIs to a Community.
254
- #
255
- # Community-level mirror of {Work.set_thumbnails}. Atlas dispatches
256
- # each non-blank URI to its matching Delegate role
257
- # (`thumbnail_image` / `thumbnail_image_2x` / `preview_image`) via
258
- # `DelegateUpdater`. Missing keys are left untouched.
259
- #
260
- # @param id [String] the Community ID.
261
- # @param thumbnail [String, nil] IIIF URI for the ~85² thumbnail.
262
- # @param thumbnail_2x [String, nil] IIIF URI for the ~170² 2x thumbnail.
263
- # @param preview [String, nil] IIIF URI for the ~500w preview image.
264
- # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
265
- # path it is signed into the assertion `sub`; on the BYO-JWT (`ATLAS_JWT`)
266
- # path it is ignored (identity lives in the token).
267
- # @return [AtlasRb::Mash] the parsed JSON response.
268
- # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
269
- # conflict that exhausted its internal retry budget (HTTP 409 with
270
- # `error: "stale_resource"`).
271
- # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
272
- # resource, so the write did not happen.
273
- # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
274
- # and body.
275
- #
276
- # @example
277
- # AtlasRb::Community.set_thumbnails(
278
- # "c-123",
279
- # thumbnail: "https://iiif.example.edu/iiif/3/m.jp2/full/!85,85/0/default.jpg",
280
- # thumbnail_2x: "https://iiif.example.edu/iiif/3/m.jp2/full/!170,170/0/default.jpg",
281
- # preview: "https://iiif.example.edu/iiif/3/m.jp2/full/500,/0/default.jpg"
282
- # )
283
- def self.set_thumbnails(id, thumbnail: nil, thumbnail_2x: nil, preview: nil, nuid: nil, on_behalf_of: nil)
284
- body = { thumbnail: thumbnail, thumbnail_2x: thumbnail_2x, preview: preview }.compact
285
- AtlasRb::Mash.new(write_resource(
286
- connection({}, nuid, on_behalf_of: on_behalf_of)
287
- .patch(ROUTE + id + '/thumbnails', JSON.dump(body))
288
- ))
289
- end
290
-
291
124
  # Fetch the Community's MODS representation in the requested format.
292
125
  #
293
126
  # @param id [String] the Community ID.
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtlasRb
4
+ # Reopens {Resource} with the writes that need no type. Atlas serves each one
5
+ # as a verb on the `/resources/{id}` sub-resource that already serves its
6
+ # `GET`, so a caller holding only a NOID never resolves the type first.
7
+ #
8
+ # Loaded after the subclasses because the typed writes delegate here, and
9
+ # because {Resource::TYPE_MAP} in `resource_types.rb` has the same ordering
10
+ # requirement.
11
+ #
12
+ # **The names are deliberately not `update` and `metadata`.** Neither typed
13
+ # name says which document it writes, and `Resource.mods` / `.permissions`
14
+ # are already taken by the reads — overloading them by arity would give one
15
+ # name two behaviours on the ACL surface.
16
+ #
17
+ # Atlas refuses a type that cannot take the write; the gem does not
18
+ # pre-check. A MODS write aimed at a FileSet answers `404`, the same as the
19
+ # `GET` on that path.
20
+ #
21
+ # **There are no typed counterparts.** One URL serves every type, so a typed
22
+ # write would name a type it could not enforce. The subclasses still *answer*
23
+ # these methods, because they inherit them — `AtlasRb::Work.tombstone(id)` is
24
+ # the same call as `AtlasRb::Resource.tombstone(id)`, and neither checks that
25
+ # `id` names a Work. That has always been true of the generic reads too;
26
+ # {Resource.find} is the call that reports a type.
27
+ class Resource
28
+ # Replace a resource's MODS document.
29
+ #
30
+ # `PUT`, not `PATCH`: the caller assembles the whole document. Descriptive
31
+ # merge logic lives in the client, so a partial document replaces rather
32
+ # than merges, and the verb says so.
33
+ #
34
+ # @param id [String] the resource's NOID.
35
+ # @param xml_path [String] path to the MODS XML to upload.
36
+ # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
37
+ # path it is signed into the assertion `sub`; on the BYO-JWT (`ATLAS_JWT`)
38
+ # path it is ignored (identity lives in the token).
39
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
40
+ # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
41
+ # omitted.
42
+ # @param origin [String, nil] the editing surface to record on the audit
43
+ # event, e.g. `"xml_editor"`. Omitted from the body when nil.
44
+ # @return [AtlasRb::Mash] the resource, unwrapped from its type key.
45
+ # @raise [AtlasRb::NotFoundError] on `404` — no such id, or a type that
46
+ # holds no MODS. The write did not happen either way.
47
+ # @raise [AtlasRb::StaleResourceError] on an optimistic-lock conflict.
48
+ # @raise [AtlasRb::ResourceError] on any other non-2xx.
49
+ #
50
+ # @example
51
+ # AtlasRb::Resource.put_mods("xsj3xmz", "/tmp/work.xml", origin: "xml_editor")
52
+ def self.put_mods(id, xml_path, nuid: nil, on_behalf_of: nil, origin: nil)
53
+ unwrap(write_resource(
54
+ multipart(nuid, on_behalf_of: on_behalf_of)
55
+ .put('/resources/' + id + '/mods', mods_upload_payload(xml_path, origin))
56
+ ))
57
+ end
58
+
59
+ # Adjust a resource's ACL.
60
+ #
61
+ # `PATCH`, and every key merges: a key you omit keeps its stored value.
62
+ # Pass an explicit empty array to clear one. So changing a single slot no
63
+ # longer needs the read-the-whole-envelope-and-write-it-back round trip.
64
+ #
65
+ # @param id [String] the resource's NOID.
66
+ # @param values [Hash] the ACL keys to change — any of `embargo`,
67
+ # `depositor`, `proxy_uploader`, `edit_users`, `read`, `edit`.
68
+ # @param nuid [String, nil] optional acting user's NUID.
69
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
70
+ # header.
71
+ # @return [AtlasRb::Mash] the resource, unwrapped from its type key.
72
+ # @raise [AtlasRb::NotFoundError] on `404` — the write did not happen.
73
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, including the `403`
74
+ # Atlas answers when a caller tries to remove a grant for a group it does
75
+ # not belong to.
76
+ #
77
+ # @example Publish, leaving every other key alone
78
+ # AtlasRb::Resource.set_permissions("xsj3xmz", { "read" => ["public"] })
79
+ def self.set_permissions(id, values, nuid: nil, on_behalf_of: nil)
80
+ unwrap(write_resource(
81
+ connection({ permissions: values }, nuid, on_behalf_of: on_behalf_of)
82
+ .patch('/resources/' + id + '/permissions')
83
+ ))
84
+ end
85
+
86
+ # Attach the three thumbnail-family IIIF Delegate URIs to a resource.
87
+ #
88
+ # Only the URIs you pass are upserted; an omitted key is left untouched.
89
+ #
90
+ # @param id [String] the resource's NOID.
91
+ # @param thumbnail [String, nil] IIIF URI for the ~85² thumbnail.
92
+ # @param thumbnail_2x [String, nil] IIIF URI for the ~170² 2x thumbnail.
93
+ # @param preview [String, nil] IIIF URI for the ~500w preview image.
94
+ # @param nuid [String, nil] optional acting user's NUID.
95
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
96
+ # header.
97
+ # @return [AtlasRb::Mash] the resource, unwrapped from its type key.
98
+ # @raise [AtlasRb::NotFoundError] on `404` — the write did not happen.
99
+ # @raise [AtlasRb::StaleResourceError] on an optimistic-lock conflict.
100
+ # @raise [AtlasRb::ResourceError] on any other non-2xx.
101
+ def self.set_thumbnails(id, thumbnail: nil, thumbnail_2x: nil, preview: nil, nuid: nil, on_behalf_of: nil)
102
+ body = { thumbnail: thumbnail, thumbnail_2x: thumbnail_2x, preview: preview }.compact
103
+ unwrap(write_resource(
104
+ connection({}, nuid, on_behalf_of: on_behalf_of)
105
+ .patch('/resources/' + id + '/thumbnails', JSON.dump(body))
106
+ ))
107
+ end
108
+
109
+ # Move a resource under a different parent.
110
+ #
111
+ # Authorization is two-sided — the caller needs the right on the moved node
112
+ # **and** on the destination. Omit `new_parent_id` to move a Community to
113
+ # the top of the tree.
114
+ #
115
+ # @param id [String] the NOID of the resource to move.
116
+ # @param new_parent_id [String, nil] the destination's NOID.
117
+ # @param nuid [String, nil] optional acting user's NUID.
118
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
119
+ # header.
120
+ # @return [AtlasRb::Mash] the resource, unwrapped from its type key.
121
+ # @raise [AtlasRb::NotFoundError] on `404` — the write did not happen.
122
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, including the `422`
123
+ # Atlas answers for an unresolvable destination or a containment refusal.
124
+ def self.reparent(id, new_parent_id = nil, nuid: nil, on_behalf_of: nil)
125
+ unwrap(write_resource(
126
+ connection({ parent_id: new_parent_id }, nuid, on_behalf_of: on_behalf_of)
127
+ .patch('/resources/' + id + '/parent')
128
+ ))
129
+ end
130
+
131
+ # Restore is the operator's counterpart and lives in
132
+ # {AtlasRb::Admin::Resource}, where the namespace is the marker.
133
+ #
134
+ # Tombstone (withdraw) a resource.
135
+ #
136
+ # Returns the **raw response** rather than raising, because Atlas refuses a
137
+ # container that still holds live children with a `422` carrying
138
+ # `has_live_children` — a legitimate answer the caller has to read, not an
139
+ # error. Reversible via {.restore}.
140
+ #
141
+ # @param id [String] the resource's NOID.
142
+ # @param nuid [String, nil] the acting user's NUID, stamped on the resource
143
+ # as `tombstoned_by`.
144
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
145
+ # header.
146
+ # @return [Faraday::Response] the raw response — read `status` yourself.
147
+ def self.tombstone(id, nuid: nil, on_behalf_of: nil)
148
+ connection({}, nuid, on_behalf_of: on_behalf_of).post('/resources/' + id + '/tombstone')
149
+ end
150
+
151
+ # Atlas answers a write with the resource under its type key, matching what
152
+ # `GET /{type}/{id}` returns. The caller of a type-agnostic write does not
153
+ # know that key, so it is unwrapped here rather than left for them to
154
+ # guess -- {Resource.find} is the call that reports a type.
155
+ def self.unwrap(body)
156
+ AtlasRb::Mash.new(body).values.first
157
+ end
158
+ private_class_method :unwrap
159
+ end
160
+ end
data/lib/atlas_rb/work.rb CHANGED
@@ -147,79 +147,12 @@ module AtlasRb
147
147
  ))["work"]
148
148
  return result if xml_path.to_s.empty?
149
149
 
150
- update(result["id"], xml_path, nuid: nuid, on_behalf_of: on_behalf_of)
150
+ # The MODS seed is a second call: create takes the parent and the
151
+ # provenance slots, and the document goes through the write that owns it.
152
+ AtlasRb::Resource.put_mods(result["id"], xml_path, nuid: nuid, on_behalf_of: on_behalf_of)
151
153
  find(result["id"], nuid: nuid, on_behalf_of: on_behalf_of)
152
154
  end
153
155
 
154
- # Move a Work to a different parent Collection.
155
- #
156
- # Wraps `PATCH /works/<id>/parent` with a `parent_id` of the new
157
- # Collection. This changes the Work's single **structural** home
158
- # (`a_member_of`) — distinct from {.add_linked_member}, which adds an
159
- # additional *linked* membership without moving the Work. Atlas
160
- # re-parents the Work and synchronously updates its ancestry index; the
161
- # structural rules (type, cycle, tombstone guards) are enforced
162
- # server-side and surface as a `422`.
163
- #
164
- # **Note**: like {.create}, the destination here is a **Collection**, but
165
- # the underlying request still uses the shared `parent_id` body key (not
166
- # `collection_id`) — every re-parent endpoint posts `{ parent_id }`.
167
- #
168
- # @param id [String] the Work ID to move.
169
- # @param new_collection_id [String] the destination Collection ID.
170
- # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
171
- # path it is signed into the assertion `sub`; on the BYO-JWT (`ATLAS_JWT`)
172
- # path it is ignored (identity lives in the token).
173
- # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
174
- # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
175
- # omitted.
176
- # @return [Hash] the updated `"work"` object, already unwrapped — the
177
- # same shape {.find} returns, reflecting the new `a_member_of`.
178
- # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
179
- # conflict that exhausted its internal retry budget (HTTP 409 with
180
- # `error: "stale_resource"`).
181
- # @raise [AtlasRb::ReparentError] if Atlas rejects the move on structural
182
- # grounds (HTTP 422 — `cycle`, `invalid_parent_type`, `tombstoned_node`,
183
- # `tombstoned_parent`, `parent_required`, `parent_not_found`). The
184
- # envelope's `error` code is exposed as `#code`.
185
- # @raise [AtlasRb::ForbiddenError] if Atlas refuses the move on
186
- # authorization grounds (HTTP 403).
187
- # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
188
- # resource, so the write did not happen.
189
- # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
190
- # and body.
191
- #
192
- # @example
193
- # AtlasRb::Work.reparent("w-789", "col-999")
194
- def self.reparent(id, new_collection_id, nuid: nil, on_behalf_of: nil)
195
- AtlasRb::Mash.new(write_resource(
196
- connection({ parent_id: new_collection_id }, nuid, on_behalf_of: on_behalf_of)
197
- .patch(ROUTE + id + '/parent')
198
- ))["work"]
199
- end
200
-
201
- # Tombstone (withdraw) a Work.
202
- #
203
- # The Work remains in Atlas storage along with its FileSets and Blobs,
204
- # but is marked as withdrawn: search and show pages return a withdrawn
205
- # stub for every user. Unlike Communities and Collections, Works are
206
- # always tombstoneable regardless of how many files they hold — the
207
- # FileSets and Blobs ride along.
208
- #
209
- # @param id [String] the Work ID.
210
- # @param nuid [String] the acting user's NUID, stamped on the resource
211
- # as `tombstoned_by` for audit purposes.
212
- # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
213
- # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
214
- # omitted.
215
- # @return [Faraday::Response] the raw response.
216
- #
217
- # @example
218
- # AtlasRb::Work.tombstone("w-789", nuid: "000000002")
219
- def self.tombstone(id, nuid: nil, on_behalf_of: nil)
220
- connection({}, nuid, on_behalf_of: on_behalf_of).post(ROUTE + id + '/tombstone')
221
- end
222
-
223
156
  # Mark a Work complete.
224
157
  #
225
158
  # Cerberus's bulk-deposit job calls this once it has confirmed all
@@ -348,107 +281,6 @@ module AtlasRb
348
281
  ))["work"]
349
282
  end
350
283
 
351
- # Replace a Work's metadata by uploading a MODS XML document.
352
- #
353
- # @param id [String] the Work ID.
354
- # @param xml_path [String] path to a MODS XML file on disk.
355
- # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
356
- # path it is signed into the assertion `sub`; on the BYO-JWT (`ATLAS_JWT`)
357
- # path it is ignored (identity lives in the token).
358
- # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
359
- # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
360
- # omitted.
361
- # @param origin [String, nil] free-text tag naming the surface that made this
362
- # edit (e.g. `"metadata_form"`, `"xml_editor"`). Atlas records it verbatim
363
- # on the audit event; omit it and the event carries no origin.
364
- # @return [Hash] the parsed JSON response from the patch.
365
- # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
366
- # resource, so the write did not happen.
367
- # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
368
- # and body.
369
- #
370
- # @example
371
- # AtlasRb::Work.update("w-789", "/tmp/work-mods.xml")
372
- #
373
- # @example Recording which editing surface made the change
374
- # AtlasRb::Work.update("w-789", "/tmp/work-mods.xml", origin: "xml_editor")
375
- def self.update(id, xml_path, nuid: nil, on_behalf_of: nil, origin: nil)
376
- AtlasRb::Mash.new(write_resource(
377
- multipart(nuid, on_behalf_of: on_behalf_of)
378
- .patch(ROUTE + id, mods_upload_payload(xml_path, origin))
379
- ))
380
- end
381
-
382
- # Patch individual descriptive-metadata fields without uploading a
383
- # full MODS document.
384
- #
385
- # Scoped to user-authored descriptive metadata only. Programmatic
386
- # writes of machine-set Delegate URIs (thumbnails, image
387
- # derivatives) have their own purpose-specific endpoints — see
388
- # {.set_thumbnails} and {.set_image_derivatives}.
389
- #
390
- # @param id [String] the Work ID.
391
- # @param values [Hash] field-level metadata updates.
392
- # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
393
- # path it is signed into the assertion `sub`; on the BYO-JWT (`ATLAS_JWT`)
394
- # path it is ignored (identity lives in the token).
395
- # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
396
- # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
397
- # omitted.
398
- # @return [Hash] the parsed JSON response.
399
- # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
400
- # resource, so the write did not happen.
401
- # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
402
- # and body.
403
- #
404
- # @example
405
- # AtlasRb::Work.metadata("w-789", title: "Revised Title")
406
- def self.metadata(id, values, nuid: nil, on_behalf_of: nil)
407
- AtlasRb::Mash.new(write_resource(
408
- connection({ metadata: values }, nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id)
409
- ))
410
- end
411
-
412
- # Attach the three thumbnail/preview Delegate URIs to a Work.
413
- #
414
- # Purpose-specific PATCH for the `thumbnail_image` /
415
- # `thumbnail_image_2x` / `preview_image` Delegate roles. Atlas
416
- # dispatches each URI to its matching role via `DelegateUpdater`.
417
- # Distinct from {.metadata} — these are machine-set IIIF URIs, not
418
- # user-authored descriptive content. Missing keys are left
419
- # untouched server-side; only the URIs you pass are upserted.
420
- #
421
- # @param id [String] the Work ID.
422
- # @param thumbnail [String, nil] IIIF URI for the ~85² thumbnail.
423
- # @param thumbnail_2x [String, nil] IIIF URI for the ~170² 2x thumbnail.
424
- # @param preview [String, nil] IIIF URI for the ~500w preview image.
425
- # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
426
- # path it is signed into the assertion `sub`; on the BYO-JWT (`ATLAS_JWT`)
427
- # path it is ignored (identity lives in the token).
428
- # @return [AtlasRb::Mash] the parsed JSON response.
429
- # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
430
- # conflict that exhausted its internal retry budget (HTTP 409 with
431
- # `error: "stale_resource"`).
432
- # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
433
- # resource, so the write did not happen.
434
- # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
435
- # and body.
436
- #
437
- # @example
438
- # AtlasRb::Work.set_thumbnails(
439
- # "w-789",
440
- # thumbnail: "https://iiif.example.edu/iiif/3/abc.jp2/full/!85,85/0/default.jpg",
441
- # thumbnail_2x: "https://iiif.example.edu/iiif/3/abc.jp2/full/!170,170/0/default.jpg",
442
- # preview: "https://iiif.example.edu/iiif/3/abc.jp2/full/500,/0/default.jpg"
443
- # )
444
- def self.set_thumbnails(id, thumbnail: nil, thumbnail_2x: nil, preview: nil, nuid: nil, on_behalf_of: nil)
445
- body = { thumbnail: thumbnail, thumbnail_2x: thumbnail_2x, preview: preview }.compact
446
- AtlasRb::Mash.new(write_resource(
447
- connection({}, nuid, on_behalf_of: on_behalf_of)
448
- .patch(ROUTE + id + '/thumbnails', JSON.dump(body))
449
- ))
450
- end
451
-
452
284
  # Attach the three image-derivative Delegate URIs to a Work.
453
285
  #
454
286
  # Sibling of {.set_thumbnails} for the `small_image` /
data/lib/atlas_rb.rb CHANGED
@@ -35,11 +35,11 @@ require_relative "atlas_rb/compilation"
35
35
  require_relative "atlas_rb/person"
36
36
  # Must follow every Resource subclass above: it names them in a map.
37
37
  require_relative "atlas_rb/resource_types"
38
+ # Same ordering requirement as resource_types: the typed writes delegate here.
39
+ require_relative "atlas_rb/resource_writes"
38
40
  require_relative "atlas_rb/user"
39
41
  require_relative "atlas_rb/admin"
40
- require_relative "atlas_rb/admin/work"
41
- require_relative "atlas_rb/admin/collection"
42
- require_relative "atlas_rb/admin/community"
42
+ require_relative "atlas_rb/admin/resource"
43
43
  require_relative "atlas_rb/system"
44
44
  require_relative "atlas_rb/system/user"
45
45
  require_relative "atlas_rb/system/token"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: atlas_rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.17.0
4
+ version: 1.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cliff
@@ -158,9 +158,7 @@ files:
158
158
  - bin/setup
159
159
  - lib/atlas_rb.rb
160
160
  - lib/atlas_rb/admin.rb
161
- - lib/atlas_rb/admin/collection.rb
162
- - lib/atlas_rb/admin/community.rb
163
- - lib/atlas_rb/admin/work.rb
161
+ - lib/atlas_rb/admin/resource.rb
164
162
  - lib/atlas_rb/audit_event.rb
165
163
  - lib/atlas_rb/authentication.rb
166
164
  - lib/atlas_rb/blob.rb
@@ -181,6 +179,7 @@ files:
181
179
  - lib/atlas_rb/person.rb
182
180
  - lib/atlas_rb/resource.rb
183
181
  - lib/atlas_rb/resource_types.rb
182
+ - lib/atlas_rb/resource_writes.rb
184
183
  - lib/atlas_rb/system.rb
185
184
  - lib/atlas_rb/system/token.rb
186
185
  - lib/atlas_rb/system/user.rb
@@ -1,62 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module AtlasRb
4
- module Admin
5
- # Destructive lifecycle operations on a {AtlasRb::Collection}.
6
- #
7
- # See {AtlasRb::Admin} for the rationale behind the namespace and the
8
- # `confirm: :i_understand` friction marker.
9
- class Collection
10
- extend AtlasRb::FaradayHelper
11
-
12
- # Atlas REST endpoint prefix.
13
- # @api private
14
- ROUTE = "/collections/"
15
-
16
- # Hard-delete a Collection — a purge, not a withdrawal.
17
- #
18
- # Removes the Collection's metadata, its descriptive-metadata
19
- # FileSet, and the OCFL objects holding the preserved bytes.
20
- # Unrecoverable — prefer {AtlasRb::Collection.tombstone} for
21
- # user-visible withdrawal. Admin-only.
22
- #
23
- # Atlas refuses with a `422` (`has_children`) while the Collection
24
- # still holds a Work or a sub-container, **including tombstoned
25
- # ones**. That is stricter than tombstone, which counts only live
26
- # members: a member left behind by a purge is orphaned for good.
27
- # Empty the tree leaf-first.
28
- #
29
- # @param id [String] the Collection ID.
30
- # @param confirm [Symbol] must be `:i_understand`. Any other value
31
- # raises `ArgumentError`.
32
- # @param nuid [String, nil] optional acting user's NUID.
33
- # @param on_behalf_of [String, nil] optional `On-Behalf-Of` NUID.
34
- # @return [Faraday::Response] the raw delete response.
35
- # @raise [ArgumentError] if `confirm:` is missing or not the
36
- # sentinel value.
37
- #
38
- # @example
39
- # AtlasRb::Admin::Collection.destroy("col-456", confirm: :i_understand)
40
- def self.destroy(id, confirm:, nuid: nil, on_behalf_of: nil)
41
- unless confirm == :i_understand
42
- raise ArgumentError,
43
- "AtlasRb::Admin::Collection.destroy requires confirm: :i_understand"
44
- end
45
- connection({}, nuid, on_behalf_of: on_behalf_of).delete(ROUTE + id)
46
- end
47
-
48
- # Restore a previously-tombstoned Collection.
49
- #
50
- # @param id [String] the Collection ID.
51
- # @param nuid [String, nil] optional acting user's NUID.
52
- # @param on_behalf_of [String, nil] optional `On-Behalf-Of` NUID.
53
- # @return [Faraday::Response] the raw response.
54
- #
55
- # @example
56
- # AtlasRb::Admin::Collection.restore("col-456")
57
- def self.restore(id, nuid: nil, on_behalf_of: nil)
58
- connection({}, nuid, on_behalf_of: on_behalf_of).post(ROUTE + id + '/restore')
59
- end
60
- end
61
- end
62
- end
@@ -1,62 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module AtlasRb
4
- module Admin
5
- # Destructive lifecycle operations on a {AtlasRb::Community}.
6
- #
7
- # See {AtlasRb::Admin} for the rationale behind the namespace and the
8
- # `confirm: :i_understand` friction marker.
9
- class Community
10
- extend AtlasRb::FaradayHelper
11
-
12
- # Atlas REST endpoint prefix.
13
- # @api private
14
- ROUTE = "/communities/"
15
-
16
- # Hard-delete a Community — a purge, not a withdrawal.
17
- #
18
- # Removes the Community's metadata, its descriptive-metadata
19
- # FileSet, and the OCFL objects holding the preserved bytes.
20
- # Unrecoverable — prefer {AtlasRb::Community.tombstone} for
21
- # user-visible withdrawal. Admin-only.
22
- #
23
- # Atlas refuses with a `422` (`has_children`) while the Community
24
- # still holds a Collection or a sub-community, **including
25
- # tombstoned ones**. That is stricter than tombstone, which counts
26
- # only live members: a member left behind by a purge is orphaned
27
- # for good. Empty the tree leaf-first.
28
- #
29
- # @param id [String] the Community ID.
30
- # @param confirm [Symbol] must be `:i_understand`. Any other value
31
- # raises `ArgumentError`.
32
- # @param nuid [String, nil] optional acting user's NUID.
33
- # @param on_behalf_of [String, nil] optional `On-Behalf-Of` NUID.
34
- # @return [Faraday::Response] the raw delete response.
35
- # @raise [ArgumentError] if `confirm:` is missing or not the
36
- # sentinel value.
37
- #
38
- # @example
39
- # AtlasRb::Admin::Community.destroy("c-123", confirm: :i_understand)
40
- def self.destroy(id, confirm:, nuid: nil, on_behalf_of: nil)
41
- unless confirm == :i_understand
42
- raise ArgumentError,
43
- "AtlasRb::Admin::Community.destroy requires confirm: :i_understand"
44
- end
45
- connection({}, nuid, on_behalf_of: on_behalf_of).delete(ROUTE + id)
46
- end
47
-
48
- # Restore a previously-tombstoned Community.
49
- #
50
- # @param id [String] the Community ID.
51
- # @param nuid [String, nil] optional acting user's NUID.
52
- # @param on_behalf_of [String, nil] optional `On-Behalf-Of` NUID.
53
- # @return [Faraday::Response] the raw response.
54
- #
55
- # @example
56
- # AtlasRb::Admin::Community.restore("c-123")
57
- def self.restore(id, nuid: nil, on_behalf_of: nil)
58
- connection({}, nuid, on_behalf_of: on_behalf_of).post(ROUTE + id + '/restore')
59
- end
60
- end
61
- end
62
- end
@@ -1,71 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module AtlasRb
4
- module Admin
5
- # Destructive lifecycle operations on a {AtlasRb::Work}.
6
- #
7
- # See {AtlasRb::Admin} for the rationale behind the namespace and the
8
- # `confirm: :i_understand` friction marker.
9
- class Work
10
- extend AtlasRb::FaradayHelper
11
-
12
- # Atlas REST endpoint prefix.
13
- # @api private
14
- ROUTE = "/works/"
15
-
16
- # Hard-delete a Work — a purge, not a withdrawal.
17
- #
18
- # Removes the Work's metadata, cascades into its FileSets and their
19
- # Blobs, and removes the OCFL objects holding the preserved bytes:
20
- # every retained revision, not only the current one. Nothing
21
- # survives but the audit row, which records the NOIDs it removed.
22
- #
23
- # Unrecoverable — prefer {AtlasRb::Work.tombstone} for the
24
- # user-visible withdrawal path, which keeps everything and can be
25
- # reversed. Admin-only.
26
- #
27
- # @param id [String] the Work ID.
28
- # @param confirm [Symbol] must be `:i_understand`. Any other value
29
- # (including the kwarg being omitted) raises `ArgumentError`.
30
- # @param nuid [String, nil] optional acting user's NUID. Falls
31
- # through to {AtlasRb.config}.default_nuid when omitted.
32
- # @param on_behalf_of [String, nil] optional NUID for the
33
- # `On-Behalf-Of` header. Falls through to
34
- # {AtlasRb.config}.default_on_behalf_of when omitted.
35
- # @return [Faraday::Response] the raw delete response.
36
- # @raise [ArgumentError] if `confirm:` is missing or not the
37
- # sentinel value.
38
- #
39
- # @example
40
- # AtlasRb::Admin::Work.destroy("w-789", confirm: :i_understand)
41
- def self.destroy(id, confirm:, nuid: nil, on_behalf_of: nil)
42
- unless confirm == :i_understand
43
- raise ArgumentError,
44
- "AtlasRb::Admin::Work.destroy requires confirm: :i_understand"
45
- end
46
- connection({}, nuid, on_behalf_of: on_behalf_of).delete(ROUTE + id)
47
- end
48
-
49
- # Restore a previously-tombstoned Work.
50
- #
51
- # Reverses a withdrawal: search and show pages stop returning a
52
- # withdrawn stub. Operator-only; typically driven from a Rails
53
- # console or a future admin panel after the library has decided a
54
- # withdrawn Work should come back.
55
- #
56
- # @param id [String] the Work ID.
57
- # @param nuid [String, nil] optional acting user's NUID. Falls
58
- # through to {AtlasRb.config}.default_nuid when omitted.
59
- # @param on_behalf_of [String, nil] optional NUID for the
60
- # `On-Behalf-Of` header. Falls through to
61
- # {AtlasRb.config}.default_on_behalf_of when omitted.
62
- # @return [Faraday::Response] the raw response.
63
- #
64
- # @example
65
- # AtlasRb::Admin::Work.restore("w-789")
66
- def self.restore(id, nuid: nil, on_behalf_of: nil)
67
- connection({}, nuid, on_behalf_of: on_behalf_of).post(ROUTE + id + '/restore')
68
- end
69
- end
70
- end
71
- end