atlas_rb 1.9.3 → 1.9.4

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: 25a2216a5796eae1514b062b18135d9f071c731f006b50e8f72d6064380f9923
4
- data.tar.gz: d8d564ef0d476aa32046c5a4294776b73217e0c4a932b5b9895f22f6843c4371
3
+ metadata.gz: 808bf4695fcba808f36a2c808885529bdff8b8dae50ddfed71cd605115aa9014
4
+ data.tar.gz: 532e5bac353cceeff1806829f4e8d8f2421051799b3a249bf3119c4001c144fc
5
5
  SHA512:
6
- metadata.gz: 685a8bf54eeffcf7590a26147455deeca64751e7161155978607c6bf6b68b29b9c982b9b6ffbca1bdbc004dcdeec0b9d1f5fe5955b17e5e9009b74c709e91f51
7
- data.tar.gz: ef296b4c28970b3ec6d3b3ec35647a90407636d174d4a6ee9d1663640fd8c9b3853f4b0371ae6c76d89774fcfe7947d86152b6e717d1ae6b0fe8a509ea8392d0
6
+ metadata.gz: 177bfa1e9c486473285eba7fac243719f08af97ad58999fdb08c9d08725981ba8dce66143943768ff12a8e08d994ca34eb02a791cd567b7f98294732fd461a2a
7
+ data.tar.gz: 0eba14aa8d5ac54b4153fd48810c90fb979ac608cc3bf2b0fb68bdb039ed9b4db763d6610839a0656374f684fc9821bde4d3f7ae336f58ac9f894aa4aee2ad81
data/.version CHANGED
@@ -1 +1 @@
1
- 1.9.3
1
+ 1.9.4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.4
4
+
5
+ ### Fixed — write bindings parsed the response body without checking the status
6
+
7
+ A write aimed at a resource that is not there raised
8
+ `JSON::ParserError: unexpected end of input at line 1 column 1`. Atlas is
9
+ correct here: it answers `head :not_found`, a `404` with an empty body. The
10
+ bindings then handed that empty body to `JSON.parse`.
11
+
12
+ The message named neither the verb nor the resource, so an operator whose XML
13
+ manifest carried a PID from another environment got five identical parser
14
+ errors and nothing to act on. `Resource.fetch_resource` already guarded the
15
+ **read** path against exactly this; the write half never got the same
16
+ treatment.
17
+
18
+ Every `create` / `update` / `metadata` / `parent` / `rollback` and sibling
19
+ across `Work`, `Collection`, `Community`, `Blob`, `FileSet`, `Compilation` and
20
+ `Person` now goes through a `write_resource` companion:
21
+
22
+ - `404` → {AtlasRb::NotFoundError}, naming the verb and path.
23
+ - `410` → the parsed body, as on the read path: an Idempotency-Key replay whose
24
+ resource has since been tombstoned answers `410 Gone` *with* the tombstone.
25
+ - any other non-2xx → {AtlasRb::ResourceError}, carrying Atlas's status + body.
26
+ - `2xx` → the parsed body, exactly as before.
27
+
28
+ `NotFoundError` subclasses `ResourceError`, so a caller that only wants "the
29
+ write failed" rescues the parent.
30
+
31
+ Note the deliberate asymmetry with the read path, which keeps returning `nil`
32
+ on a `404`: that answers a read, but not a write — the caller asked for a
33
+ change and did not get one.
34
+
35
+ Blast radius is which exception surfaces, not whether one does. The typed
36
+ `403` / `422` translations raise inside the Faraday stack, so they still fire
37
+ first; a `422` the middleware passes through on purpose (`tombstone`'s
38
+ `has_live_children`) is unaffected, because `tombstone`, `destroy` and
39
+ `complete` return the raw response and never parsed.
40
+
3
41
  ## 1.9.3
4
42
 
5
43
  ### Added — `depositor:` on `Collection.create` and `Community.create`
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- atlas_rb (1.9.3)
4
+ atlas_rb (1.9.4)
5
5
  faraday (~> 2.7)
6
6
  faraday-follow_redirects (~> 0.3.0)
7
7
  faraday-multipart (~> 1)
data/README.md CHANGED
@@ -438,6 +438,43 @@ tombstoned resources come back flagged (`"tombstoned" => true`), so index
438
438
  by `"noid"` rather than assuming positional correspondence. NOIDs only —
439
439
  raw Valkyrie ids are not a supported input.
440
440
 
441
+ ### Missing resources: `nil` on a read, a raise on a write
442
+
443
+ The two halves of the API answer an absent resource differently, on purpose.
444
+
445
+ A **read** returns `nil`. "There is nothing there" is a legitimate answer to a
446
+ question, and callers already nil-check a `find`:
447
+
448
+ ```ruby
449
+ AtlasRb::Work.find("doesnotexist") # => nil
450
+ ```
451
+
452
+ A **write** raises `AtlasRb::NotFoundError`. The caller asked for a change and
453
+ did not get one, so `nil` would invite the silent failure the read guard was
454
+ written to prevent:
455
+
456
+ ```ruby
457
+ begin
458
+ AtlasRb::Work.update("doesnotexist", "/tmp/mods.xml")
459
+ rescue AtlasRb::NotFoundError => e
460
+ e.status # => 404
461
+ e.message # => "PATCH /works/doesnotexist → 404 (no such resource)"
462
+ end
463
+ ```
464
+
465
+ This covers every `create` / `update` / `metadata` / `parent` / `rollback` and
466
+ their siblings on `Work`, `Collection`, `Community`, `Blob`, `FileSet`,
467
+ `Compilation` and `Person`. Any other non-2xx a write gets raises
468
+ `AtlasRb::ResourceError`, which `NotFoundError` subclasses — so a caller that
469
+ only wants "the write failed" rescues the parent.
470
+
471
+ Two neighbours are deliberately untouched. A `410 Gone` is returned, not
472
+ raised, the same way a read returns it: an `Idempotency-Key` replay whose
473
+ resource has since been tombstoned answers `410` *with* the tombstone, and the
474
+ caller reads `tombstoned`. And the typed `403` / `422` translations above still
475
+ fire first, while `tombstone` / `destroy` / `complete` keep returning the raw
476
+ Faraday response for the caller to read.
477
+
441
478
  ## End-to-end example
442
479
 
443
480
  JSON responses come back as `AtlasRb::Mash` (a `Hashie::Mash` subclass), so
data/lib/atlas_rb/blob.rb CHANGED
@@ -157,6 +157,10 @@ module AtlasRb
157
157
  # `"digest"` (the recorded fixity digest, `"sha512:<hex>"`).
158
158
  # @raise [AtlasRb::FixityMismatchError] if `expected_digest` was supplied and
159
159
  # the uploaded bytes did not match (or the algorithm is unsupported).
160
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
161
+ # resource, so the write did not happen.
162
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
163
+ # and body.
160
164
  #
161
165
  # @note Streams the file (FD closed deterministically); a multi-GB upload is
162
166
  # not buffered in memory. See {AtlasRb::FaradayHelper#with_file_part}.
@@ -175,9 +179,9 @@ module AtlasRb
175
179
  payload = { work_id: id, original_filename: original_filename, binary: part }
176
180
  payload[:expected_digest] = expected_digest if expected_digest
177
181
 
178
- AtlasRb::Mash.new(JSON.parse(
182
+ AtlasRb::Mash.new(write_resource(
179
183
  multipart(nuid, on_behalf_of: on_behalf_of, idempotency_key: idempotency_key)
180
- .post(ROUTE, payload)&.body
184
+ .post(ROUTE, payload)
181
185
  ))['blob']
182
186
  end
183
187
  end
@@ -223,6 +227,10 @@ module AtlasRb
223
227
  # `"blob"`, with a refreshed `"digest"` for the new revision).
224
228
  # @raise [AtlasRb::FixityMismatchError] if `expected_digest` was supplied and
225
229
  # the uploaded bytes did not match (or the algorithm is unsupported).
230
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
231
+ # resource, so the write did not happen.
232
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
233
+ # and body.
226
234
  #
227
235
  # @note Streams the file with the FD closed deterministically — see {.create}.
228
236
  #
@@ -236,9 +244,9 @@ module AtlasRb
236
244
  payload = { binary: part }
237
245
  payload[:expected_digest] = expected_digest if expected_digest
238
246
 
239
- AtlasRb::Mash.new(JSON.parse(
247
+ AtlasRb::Mash.new(write_resource(
240
248
  multipart(nuid, on_behalf_of: on_behalf_of, idempotency_key: idempotency_key)
241
- .patch(ROUTE + id, payload)&.body
249
+ .patch(ROUTE + id, payload)
242
250
  ))
243
251
  end
244
252
  end
@@ -324,8 +332,9 @@ module AtlasRb
324
332
  # recopied. Avoids a full round-trip of the bytes back through the caller
325
333
  # (vs. re-streaming {.version_content} into {.update}).
326
334
  #
327
- # Pass a `version_id` obtained from {.versions}. An unknown id or version
328
- # yields a `404` (raw Faraday response).
335
+ # Pass a `version_id` obtained from {.versions}. Atlas answers an unknown id
336
+ # or version with a `404`, which raises {AtlasRb::NotFoundError} — a write
337
+ # that did not happen must not read like one that did.
329
338
  #
330
339
  # @param id [String] the Blob ID.
331
340
  # @param version_id [String] the OCFL version label to reinstate, e.g. `"v1"`.
@@ -337,13 +346,17 @@ module AtlasRb
337
346
  # omitted.
338
347
  # @return [AtlasRb::Mash] the updated `"blob"` payload (NOID unchanged,
339
348
  # `"digest"` refreshed to the reinstated bytes).
349
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
350
+ # resource, so the write did not happen.
351
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
352
+ # and body.
340
353
  #
341
354
  # @example
342
355
  # AtlasRb::Blob.rollback("b-321", "v1")
343
356
  def self.rollback(id, version_id, nuid: nil, on_behalf_of: nil)
344
- AtlasRb::Mash.new(JSON.parse(
357
+ AtlasRb::Mash.new(write_resource(
345
358
  connection({}, nuid, on_behalf_of: on_behalf_of)
346
- .post("#{ROUTE}#{id}/rollback", JSON.dump(version_id: version_id))&.body
359
+ .post("#{ROUTE}#{id}/rollback", JSON.dump(version_id: version_id))
347
360
  ))['blob']
348
361
  end
349
362
  end
@@ -66,6 +66,10 @@ module AtlasRb
66
66
  # is immutable post-create; there is no setter on the update surface.
67
67
  # @return [Hash] the created Collection payload (post-update if
68
68
  # `xml_path` was supplied).
69
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
70
+ # resource, so the write did not happen.
71
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
72
+ # and body.
69
73
  #
70
74
  # @example A featured showcase collection
71
75
  # AtlasRb::Collection.create("c-123", featured: true)
@@ -75,8 +79,8 @@ module AtlasRb
75
79
  def self.create(id, xml_path = nil, featured: false, nuid: nil, on_behalf_of: nil, depositor: nil)
76
80
  params = { parent_id: id, featured: featured }
77
81
  params[:depositor] = depositor if depositor
78
- result = AtlasRb::Mash.new(JSON.parse(
79
- connection(params, nuid, on_behalf_of: on_behalf_of).post(ROUTE)&.body
82
+ result = AtlasRb::Mash.new(write_resource(
83
+ connection(params, nuid, on_behalf_of: on_behalf_of).post(ROUTE)
80
84
  ))["collection"]
81
85
  return result if xml_path.to_s.empty?
82
86
 
@@ -96,12 +100,16 @@ module AtlasRb
96
100
  # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
97
101
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when omitted.
98
102
  # @return [AtlasRb::Mash] the updated `"collection"` object, already unwrapped.
103
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
104
+ # resource, so the write did not happen.
105
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
106
+ # and body.
99
107
  #
100
108
  # @example
101
109
  # AtlasRb::Collection.set_featured("col-456", true)
102
110
  def self.set_featured(id, featured, nuid: nil, on_behalf_of: nil)
103
- AtlasRb::Mash.new(JSON.parse(
104
- connection({ featured: featured }, nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id)&.body
111
+ AtlasRb::Mash.new(write_resource(
112
+ connection({ featured: featured }, nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id)
105
113
  ))["collection"]
106
114
  end
107
115
 
@@ -134,13 +142,17 @@ module AtlasRb
134
142
  # envelope's `error` code is exposed as `#code`.
135
143
  # @raise [AtlasRb::ForbiddenError] if Atlas refuses the move on
136
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.
137
149
  #
138
150
  # @example
139
151
  # AtlasRb::Collection.reparent("col-456", "c-999")
140
152
  def self.reparent(id, new_parent_id, nuid: nil, on_behalf_of: nil)
141
- AtlasRb::Mash.new(JSON.parse(
153
+ AtlasRb::Mash.new(write_resource(
142
154
  connection({ parent_id: new_parent_id }, nuid, on_behalf_of: on_behalf_of)
143
- .patch(ROUTE + id + '/parent')&.body
155
+ .patch(ROUTE + id + '/parent')
144
156
  ))["collection"]
145
157
  end
146
158
 
@@ -200,6 +212,10 @@ module AtlasRb
200
212
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
201
213
  # omitted.
202
214
  # @return [Hash] the parsed JSON response from the patch.
215
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
216
+ # resource, so the write did not happen.
217
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
218
+ # and body.
203
219
  #
204
220
  # @example
205
221
  # AtlasRb::Collection.update("col-456", "/tmp/collection-mods.xml")
@@ -207,8 +223,8 @@ module AtlasRb
207
223
  payload = { binary: Faraday::Multipart::FilePart.new(File.open(xml_path),
208
224
  "application/xml",
209
225
  File.basename(xml_path)) }
210
- AtlasRb::Mash.new(JSON.parse(
211
- multipart(nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id, payload)&.body
226
+ AtlasRb::Mash.new(write_resource(
227
+ multipart(nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id, payload)
212
228
  ))
213
229
  end
214
230
 
@@ -228,12 +244,16 @@ module AtlasRb
228
244
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
229
245
  # omitted.
230
246
  # @return [Hash] the parsed JSON response.
247
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
248
+ # resource, so the write did not happen.
249
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
250
+ # and body.
231
251
  #
232
252
  # @example
233
253
  # AtlasRb::Collection.metadata("col-456", title: "Renamed Collection")
234
254
  def self.metadata(id, values, nuid: nil, on_behalf_of: nil)
235
- AtlasRb::Mash.new(JSON.parse(
236
- connection({ metadata: values }, nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id)&.body
255
+ AtlasRb::Mash.new(write_resource(
256
+ connection({ metadata: values }, nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id)
237
257
  ))
238
258
  end
239
259
 
@@ -255,6 +275,10 @@ module AtlasRb
255
275
  # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
256
276
  # conflict that exhausted its internal retry budget (HTTP 409 with
257
277
  # `error: "stale_resource"`).
278
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
279
+ # resource, so the write did not happen.
280
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
281
+ # and body.
258
282
  #
259
283
  # @example
260
284
  # AtlasRb::Collection.set_thumbnails(
@@ -265,9 +289,9 @@ module AtlasRb
265
289
  # )
266
290
  def self.set_thumbnails(id, thumbnail: nil, thumbnail_2x: nil, preview: nil, nuid: nil, on_behalf_of: nil)
267
291
  body = { thumbnail: thumbnail, thumbnail_2x: thumbnail_2x, preview: preview }.compact
268
- AtlasRb::Mash.new(JSON.parse(
292
+ AtlasRb::Mash.new(write_resource(
269
293
  connection({}, nuid, on_behalf_of: on_behalf_of)
270
- .patch(ROUTE + id + '/thumbnails', JSON.dump(body))&.body
294
+ .patch(ROUTE + id + '/thumbnails', JSON.dump(body))
271
295
  ))
272
296
  end
273
297
 
@@ -61,6 +61,10 @@ module AtlasRb
61
61
  # is immutable post-create; there is no setter on the update surface.
62
62
  # @return [Hash] the created Community payload (post-update if `xml_path`
63
63
  # was supplied).
64
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
65
+ # resource, so the write did not happen.
66
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
67
+ # and body.
64
68
  #
65
69
  # @example Top-level community, no metadata
66
70
  # AtlasRb::Community.create(nil)
@@ -73,8 +77,8 @@ module AtlasRb
73
77
  def self.create(id = nil, xml_path = nil, nuid: nil, on_behalf_of: nil, depositor: nil)
74
78
  params = { parent_id: id }
75
79
  params[:depositor] = depositor if depositor
76
- result = AtlasRb::Mash.new(JSON.parse(
77
- connection(params, nuid, on_behalf_of: on_behalf_of).post(ROUTE)&.body
80
+ result = AtlasRb::Mash.new(write_resource(
81
+ connection(params, nuid, on_behalf_of: on_behalf_of).post(ROUTE)
78
82
  ))["community"]
79
83
  return result if xml_path.to_s.empty?
80
84
 
@@ -113,6 +117,10 @@ module AtlasRb
113
117
  # `parent_not_found`). The envelope's `error` code is exposed as `#code`.
114
118
  # @raise [AtlasRb::ForbiddenError] if Atlas refuses the move on
115
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.
116
124
  #
117
125
  # @example Move under another Community
118
126
  # AtlasRb::Community.reparent("c-123", "c-999")
@@ -120,9 +128,9 @@ module AtlasRb
120
128
  # @example Promote to a top-level Community
121
129
  # AtlasRb::Community.reparent("c-123", nil)
122
130
  def self.reparent(id, new_parent_id, nuid: nil, on_behalf_of: nil)
123
- AtlasRb::Mash.new(JSON.parse(
131
+ AtlasRb::Mash.new(write_resource(
124
132
  connection({ parent_id: new_parent_id }, nuid, on_behalf_of: on_behalf_of)
125
- .patch(ROUTE + id + '/parent')&.body
133
+ .patch(ROUTE + id + '/parent')
126
134
  ))["community"]
127
135
  end
128
136
 
@@ -183,6 +191,10 @@ module AtlasRb
183
191
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
184
192
  # omitted.
185
193
  # @return [Hash] the parsed JSON response from the patch.
194
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
195
+ # resource, so the write did not happen.
196
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
197
+ # and body.
186
198
  #
187
199
  # @example
188
200
  # AtlasRb::Community.update("c-123", "/tmp/community-mods.xml")
@@ -190,8 +202,8 @@ module AtlasRb
190
202
  payload = { binary: Faraday::Multipart::FilePart.new(File.open(xml_path),
191
203
  "application/xml",
192
204
  File.basename(xml_path)) }
193
- AtlasRb::Mash.new(JSON.parse(
194
- multipart(nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id, payload)&.body
205
+ AtlasRb::Mash.new(write_resource(
206
+ multipart(nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id, payload)
195
207
  ))
196
208
  end
197
209
 
@@ -212,12 +224,16 @@ module AtlasRb
212
224
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
213
225
  # omitted.
214
226
  # @return [Hash] the parsed JSON response.
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.
215
231
  #
216
232
  # @example
217
233
  # AtlasRb::Community.metadata("c-123", title: "New Name")
218
234
  def self.metadata(id, values, nuid: nil, on_behalf_of: nil)
219
- AtlasRb::Mash.new(JSON.parse(
220
- connection({ metadata: values }, nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id)&.body
235
+ AtlasRb::Mash.new(write_resource(
236
+ connection({ metadata: values }, nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id)
221
237
  ))
222
238
  end
223
239
 
@@ -239,6 +255,10 @@ module AtlasRb
239
255
  # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
240
256
  # conflict that exhausted its internal retry budget (HTTP 409 with
241
257
  # `error: "stale_resource"`).
258
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
259
+ # resource, so the write did not happen.
260
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
261
+ # and body.
242
262
  #
243
263
  # @example
244
264
  # AtlasRb::Community.set_thumbnails(
@@ -249,9 +269,9 @@ module AtlasRb
249
269
  # )
250
270
  def self.set_thumbnails(id, thumbnail: nil, thumbnail_2x: nil, preview: nil, nuid: nil, on_behalf_of: nil)
251
271
  body = { thumbnail: thumbnail, thumbnail_2x: thumbnail_2x, preview: preview }.compact
252
- AtlasRb::Mash.new(JSON.parse(
272
+ AtlasRb::Mash.new(write_resource(
253
273
  connection({}, nuid, on_behalf_of: on_behalf_of)
254
- .patch(ROUTE + id + '/thumbnails', JSON.dump(body))&.body
274
+ .patch(ROUTE + id + '/thumbnails', JSON.dump(body))
255
275
  ))
256
276
  end
257
277
 
@@ -136,6 +136,10 @@ module AtlasRb
136
136
  # e.g. a blank title).
137
137
  # @raise [AtlasRb::ForbiddenError] if the caller may not create Sets
138
138
  # (guests cannot).
139
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
140
+ # resource, so the write did not happen.
141
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
142
+ # and body.
139
143
  #
140
144
  # @example
141
145
  # AtlasRb::Compilation.create("Course readings",
@@ -144,8 +148,8 @@ module AtlasRb
144
148
  def self.create(title, description: nil, nuid: nil, on_behalf_of: nil)
145
149
  params = { title: title }
146
150
  params[:description] = description if description
147
- AtlasRb::Mash.new(JSON.parse(
148
- connection(params, nuid, on_behalf_of: on_behalf_of).post(ROUTE)&.body
151
+ AtlasRb::Mash.new(write_resource(
152
+ connection(params, nuid, on_behalf_of: on_behalf_of).post(ROUTE)
149
153
  ))["compilation"]
150
154
  end
151
155
 
@@ -172,6 +176,10 @@ module AtlasRb
172
176
  # @raise [AtlasRb::CompilationError] if Atlas rejects the update (422).
173
177
  # @raise [AtlasRb::ForbiddenError] if the caller lacks edit rights
174
178
  # (owner / explicit grant / admin).
179
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
180
+ # resource, so the write did not happen.
181
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
182
+ # and body.
175
183
  #
176
184
  # @example Rename
177
185
  # AtlasRb::Compilation.update("c-123", title: "Renamed", nuid: "000000002")
@@ -185,8 +193,8 @@ module AtlasRb
185
193
  params[:title] = title if title
186
194
  params[:description] = description if description
187
195
  params[:permissions] = permissions if permissions
188
- AtlasRb::Mash.new(JSON.parse(
189
- connection(params, nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id)&.body
196
+ AtlasRb::Mash.new(write_resource(
197
+ connection(params, nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id)
190
198
  ))["compilation"]
191
199
  end
192
200
 
@@ -231,13 +239,17 @@ module AtlasRb
231
239
  # the full recipe, so chip counts refresh without a follow-up {.find}.
232
240
  # @raise [AtlasRb::CompilationError] if the noid is not a Collection (422).
233
241
  # @raise [AtlasRb::ForbiddenError] if the caller lacks edit rights.
242
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
243
+ # resource, so the write did not happen.
244
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
245
+ # and body.
234
246
  #
235
247
  # @example
236
248
  # AtlasRb::Compilation.add_included_collection("c-123", "col-456", nuid: "000000002")
237
249
  def self.add_included_collection(id, collection_id, nuid: nil, on_behalf_of: nil)
238
- AtlasRb::Mash.new(JSON.parse(
250
+ AtlasRb::Mash.new(write_resource(
239
251
  connection({ collection_id: collection_id }, nuid, on_behalf_of: on_behalf_of)
240
- .post(ROUTE + id + '/included_collections')&.body
252
+ .post(ROUTE + id + '/included_collections')
241
253
  ))["compilation"]
242
254
  end
243
255
 
@@ -256,13 +268,17 @@ module AtlasRb
256
268
  # omitted.
257
269
  # @return [Hash] the updated `"compilation"` object.
258
270
  # @raise [AtlasRb::ForbiddenError] if the caller lacks edit rights.
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.
259
275
  #
260
276
  # @example
261
277
  # AtlasRb::Compilation.remove_included_collection("c-123", "col-456", nuid: "000000002")
262
278
  def self.remove_included_collection(id, collection_id, nuid: nil, on_behalf_of: nil)
263
- AtlasRb::Mash.new(JSON.parse(
279
+ AtlasRb::Mash.new(write_resource(
264
280
  connection({}, nuid, on_behalf_of: on_behalf_of)
265
- .delete(ROUTE + id + '/included_collections/' + collection_id)&.body
281
+ .delete(ROUTE + id + '/included_collections/' + collection_id)
266
282
  ))["compilation"]
267
283
  end
268
284
 
@@ -281,13 +297,17 @@ module AtlasRb
281
297
  # @return [Hash] the updated `"compilation"` object.
282
298
  # @raise [AtlasRb::CompilationError] if the noid is not a Work (422).
283
299
  # @raise [AtlasRb::ForbiddenError] if the caller lacks edit rights.
300
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
301
+ # resource, so the write did not happen.
302
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
303
+ # and body.
284
304
  #
285
305
  # @example
286
306
  # AtlasRb::Compilation.add_included_work("c-123", "w-789", nuid: "000000002")
287
307
  def self.add_included_work(id, work_id, nuid: nil, on_behalf_of: nil)
288
- AtlasRb::Mash.new(JSON.parse(
308
+ AtlasRb::Mash.new(write_resource(
289
309
  connection({ work_id: work_id }, nuid, on_behalf_of: on_behalf_of)
290
- .post(ROUTE + id + '/included_works')&.body
310
+ .post(ROUTE + id + '/included_works')
291
311
  ))["compilation"]
292
312
  end
293
313
 
@@ -303,13 +323,17 @@ module AtlasRb
303
323
  # omitted.
304
324
  # @return [Hash] the updated `"compilation"` object.
305
325
  # @raise [AtlasRb::ForbiddenError] if the caller lacks edit rights.
326
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
327
+ # resource, so the write did not happen.
328
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
329
+ # and body.
306
330
  #
307
331
  # @example
308
332
  # AtlasRb::Compilation.remove_included_work("c-123", "w-789", nuid: "000000002")
309
333
  def self.remove_included_work(id, work_id, nuid: nil, on_behalf_of: nil)
310
- AtlasRb::Mash.new(JSON.parse(
334
+ AtlasRb::Mash.new(write_resource(
311
335
  connection({}, nuid, on_behalf_of: on_behalf_of)
312
- .delete(ROUTE + id + '/included_works/' + work_id)&.body
336
+ .delete(ROUTE + id + '/included_works/' + work_id)
313
337
  ))["compilation"]
314
338
  end
315
339
 
@@ -331,13 +355,17 @@ module AtlasRb
331
355
  # @return [Hash] the updated `"compilation"` object.
332
356
  # @raise [AtlasRb::CompilationError] if the noid is not a Work (422).
333
357
  # @raise [AtlasRb::ForbiddenError] if the caller lacks edit rights.
358
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
359
+ # resource, so the write did not happen.
360
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
361
+ # and body.
334
362
  #
335
363
  # @example
336
364
  # AtlasRb::Compilation.add_exclusion("c-123", "w-789", nuid: "000000002")
337
365
  def self.add_exclusion(id, work_id, nuid: nil, on_behalf_of: nil)
338
- AtlasRb::Mash.new(JSON.parse(
366
+ AtlasRb::Mash.new(write_resource(
339
367
  connection({ work_id: work_id }, nuid, on_behalf_of: on_behalf_of)
340
- .post(ROUTE + id + '/exclusions')&.body
368
+ .post(ROUTE + id + '/exclusions')
341
369
  ))["compilation"]
342
370
  end
343
371
 
@@ -353,13 +381,17 @@ module AtlasRb
353
381
  # omitted.
354
382
  # @return [Hash] the updated `"compilation"` object.
355
383
  # @raise [AtlasRb::ForbiddenError] if the caller lacks edit rights.
384
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
385
+ # resource, so the write did not happen.
386
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
387
+ # and body.
356
388
  #
357
389
  # @example
358
390
  # AtlasRb::Compilation.remove_exclusion("c-123", "w-789", nuid: "000000002")
359
391
  def self.remove_exclusion(id, work_id, nuid: nil, on_behalf_of: nil)
360
- AtlasRb::Mash.new(JSON.parse(
392
+ AtlasRb::Mash.new(write_resource(
361
393
  connection({}, nuid, on_behalf_of: on_behalf_of)
362
- .delete(ROUTE + id + '/exclusions/' + work_id)&.body
394
+ .delete(ROUTE + id + '/exclusions/' + work_id)
363
395
  ))["compilation"]
364
396
  end
365
397
 
@@ -275,12 +275,16 @@ module AtlasRb
275
275
  end
276
276
  end
277
277
 
278
- # Raised by the typed single-resource readers ({Resource.find} and the
279
- # `Work` / `Collection` / `Community` / `FileSet` / `Person` / `Compilation`
280
- # / `Blob` / `Delegate` overrides) when Atlas answers the `GET` with a
281
- # non-2xx that is **not** a `404` or a `410` — i.e. an error envelope
282
- # (`{ "error" => ... }`, status 400/401/403/422) on what the caller treated
283
- # as a plain read.
278
+ # Raised when Atlas answers a single-resource request with a non-2xx the
279
+ # binding cannot represent as a return value:
280
+ #
281
+ # - on a **read** ({Resource.find} and the `Work` / `Collection` /
282
+ # `Community` / `FileSet` / `Person` / `Compilation` / `Blob` / `Delegate`
283
+ # overrides), any non-2xx that is **not** a `404` or a `410` — i.e. an error
284
+ # envelope (`{ "error" => ... }`, status 400/401/403/422) on what the caller
285
+ # treated as a plain read.
286
+ # - on a **write** (`create`, `update`, `metadata`, and their siblings), any
287
+ # non-2xx at all; a `404` there is the {NotFoundError} subclass.
284
288
  #
285
289
  # Before this existed, `find` unwrapped the success body by a fixed key
286
290
  # (`["work"]`, `["collection"]`, …); on an error envelope that key is
@@ -291,11 +295,11 @@ module AtlasRb
291
295
  # real cause (e.g. `… → 401: {"error":"invalid bearer token"}`) is
292
296
  # attributable everywhere `find` is used.
293
297
  #
294
- # A genuine `404` is **not** this — it stays a clean `nil` return, since
295
- # "not found" is a normal `find` outcome callers already nil-check. A `410`
296
- # is also **not** this — a tombstoned resource comes back as `410 Gone` with
297
- # its full body, which `find` returns (the caller nil-checks / reads
298
- # `tombstoned`), rather than an error to raise.
298
+ # On the read path a genuine `404` is **not** this — it stays a clean `nil`
299
+ # return, since "not found" is a normal `find` outcome callers already
300
+ # nil-check. A `410` is also **not** this — a tombstoned resource comes back
301
+ # as `410 Gone` with its full body, which `find` returns (the caller
302
+ # nil-checks / reads `tombstoned`), rather than an error to raise.
299
303
  #
300
304
  # @note Authorization failures on the narrow re-parent / linked-member /
301
305
  # Compilation write paths surface as {ForbiddenError} via
@@ -322,6 +326,26 @@ module AtlasRb
322
326
  end
323
327
  end
324
328
 
329
+ # Raised when a **write** binding (`create`, `update`, `metadata`, `parent`,
330
+ # `rollback`, and their siblings) targets a resource Atlas answers `404` for —
331
+ # a mistyped or foreign NOID, or one re-parented away.
332
+ #
333
+ # A `404` on the read path is not this: {Resource.find} and its typed
334
+ # overrides return a clean `nil`, because "there is nothing there" answers a
335
+ # read. It cannot answer a write — the caller asked for a change that did not
336
+ # happen — so the write path raises instead of coercing to `nil`. Atlas
337
+ # renders the refusal as `head :not_found` with an empty body, which is what
338
+ # made the old blind `JSON.parse` surface it as
339
+ # `JSON::ParserError: unexpected end of input`: a message naming neither the
340
+ # resource nor the verb.
341
+ #
342
+ # rescue AtlasRb::NotFoundError => e
343
+ # report.rows.failed(row, "no such object in this repository")
344
+ #
345
+ # A subclass of {ResourceError}, so it carries the same `status` / `body` and
346
+ # a caller that only wants "the write failed" can rescue the parent.
347
+ class NotFoundError < ResourceError; end
348
+
325
349
  # Raised when the transport has no way to authenticate a relay request:
326
350
  # neither `ATLAS_JWT` (BYO-JWT mode) nor a signing key
327
351
  # ({AtlasRb.config#assertion_signing_key}, relay-signing mode) is configured.
@@ -59,6 +59,10 @@ module AtlasRb
59
59
  # @return [Hash] the created `"file_set"` payload, including its `"id"`
60
60
  # which can then be passed to {.update} to attach a binary, and its
61
61
  # `"position"` (`nil` when unordered).
62
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
63
+ # resource, so the write did not happen.
64
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
65
+ # and body.
62
66
  #
63
67
  # @example
64
68
  # fs = AtlasRb::FileSet.create("w-789", "primary")
@@ -74,9 +78,9 @@ module AtlasRb
74
78
  def self.create(id, classification, position: nil, idempotency_key: nil, nuid: nil, on_behalf_of: nil)
75
79
  params = { work_id: id, classification: classification }
76
80
  params[:position] = position if position
77
- AtlasRb::Mash.new(JSON.parse(
81
+ AtlasRb::Mash.new(write_resource(
78
82
  connection(params, nuid,
79
- on_behalf_of: on_behalf_of, idempotency_key: idempotency_key).post(ROUTE)&.body
83
+ on_behalf_of: on_behalf_of, idempotency_key: idempotency_key).post(ROUTE)
80
84
  ))["file_set"]
81
85
  end
82
86
 
@@ -123,6 +127,10 @@ module AtlasRb
123
127
  # @return [Hash] the parsed JSON response from the patch (the `"file_set"`).
124
128
  # @raise [AtlasRb::FixityMismatchError] if `expected_digest` was supplied and
125
129
  # the uploaded bytes did not match (or the algorithm is unsupported).
130
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
131
+ # resource, so the write did not happen.
132
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
133
+ # and body.
126
134
  #
127
135
  # @note Streams the file with the FD closed deterministically — see
128
136
  # {Blob.create} / {AtlasRb::FaradayHelper#with_file_part}.
@@ -141,9 +149,9 @@ module AtlasRb
141
149
  payload[:original_filename] = original_filename if original_filename
142
150
  payload[:expected_digest] = expected_digest if expected_digest
143
151
 
144
- AtlasRb::Mash.new(JSON.parse(
152
+ AtlasRb::Mash.new(write_resource(
145
153
  multipart(nuid, on_behalf_of: on_behalf_of, idempotency_key: idempotency_key)
146
- .patch(ROUTE + id, payload)&.body
154
+ .patch(ROUTE + id, payload)
147
155
  ))
148
156
  end
149
157
  end
@@ -172,6 +180,10 @@ module AtlasRb
172
180
  # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
173
181
  # conflict that exhausted its internal retry budget (HTTP 409 with
174
182
  # `error: "stale_resource"`).
183
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
184
+ # resource, so the write did not happen.
185
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
186
+ # and body.
175
187
  #
176
188
  # @example
177
189
  # AtlasRb::FileSet.set_iiif_service(
@@ -179,9 +191,9 @@ module AtlasRb
179
191
  # "https://iiif.example.edu/iiif/3/abc.jp2"
180
192
  # )
181
193
  def self.set_iiif_service(id, uri, nuid: nil, on_behalf_of: nil)
182
- AtlasRb::Mash.new(JSON.parse(
194
+ AtlasRb::Mash.new(write_resource(
183
195
  connection({}, nuid, on_behalf_of: on_behalf_of)
184
- .patch(ROUTE + id + "/iiif_service", JSON.dump({ uri: uri }))&.body
196
+ .patch(ROUTE + id + "/iiif_service", JSON.dump({ uri: uri }))
185
197
  ))
186
198
  end
187
199
  end
@@ -89,10 +89,14 @@ module AtlasRb
89
89
  # @param on_behalf_of [String, nil] acting-as target (the acting principal
90
90
  # itself comes from the ambient AtlasRb.config.default_nuid).
91
91
  # @return [AtlasRb::Mash] the unwrapped `"person"` object.
92
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
93
+ # resource, so the write did not happen.
94
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
95
+ # and body.
92
96
  def self.create(nuid:, display_name:, bio: nil, orcid: nil, title: nil, on_behalf_of: nil)
93
97
  body = { nuid: nuid, display_name: display_name, bio: bio, orcid: orcid, title: title }.compact
94
- AtlasRb::Mash.new(JSON.parse(
95
- connection({}, nil, on_behalf_of: on_behalf_of).post(ROUTE, JSON.dump(body))&.body
98
+ AtlasRb::Mash.new(write_resource(
99
+ connection({}, nil, on_behalf_of: on_behalf_of).post(ROUTE, JSON.dump(body))
96
100
  ))["person"]
97
101
  end
98
102
 
@@ -107,10 +111,14 @@ module AtlasRb
107
111
  # @param nuid [String, nil] acting principal.
108
112
  # @param on_behalf_of [String, nil] acting-as target.
109
113
  # @return [AtlasRb::Mash] the unwrapped, updated `"person"` object.
114
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
115
+ # resource, so the write did not happen.
116
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
117
+ # and body.
110
118
  def self.update(id, display_name: nil, bio: nil, orcid: nil, title: nil, nuid: nil, on_behalf_of: nil)
111
119
  body = { display_name: display_name, bio: bio, orcid: orcid, title: title }.compact
112
- AtlasRb::Mash.new(JSON.parse(
113
- connection({}, nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id, JSON.dump(body))&.body
120
+ AtlasRb::Mash.new(write_resource(
121
+ connection({}, nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id, JSON.dump(body))
114
122
  ))["person"]
115
123
  end
116
124
 
@@ -122,10 +130,14 @@ module AtlasRb
122
130
  # @param on_behalf_of [String, nil] acting-as target.
123
131
  # @return [AtlasRb::Mash] the unwrapped `"person"` object, with the updated
124
132
  # `affiliated_community_ids`.
133
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
134
+ # resource, so the write did not happen.
135
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
136
+ # and body.
125
137
  def self.add_affiliation(id, community_id, nuid: nil, on_behalf_of: nil)
126
- AtlasRb::Mash.new(JSON.parse(
138
+ AtlasRb::Mash.new(write_resource(
127
139
  connection({}, nuid, on_behalf_of: on_behalf_of)
128
- .post(ROUTE + id + "/affiliations", JSON.dump(community_id: community_id))&.body
140
+ .post(ROUTE + id + "/affiliations", JSON.dump(community_id: community_id))
129
141
  ))["person"]
130
142
  end
131
143
 
@@ -136,10 +148,14 @@ module AtlasRb
136
148
  # @param nuid [String, nil] acting principal.
137
149
  # @param on_behalf_of [String, nil] acting-as target.
138
150
  # @return [AtlasRb::Mash] the unwrapped `"person"` object.
151
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
152
+ # resource, so the write did not happen.
153
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
154
+ # and body.
139
155
  def self.remove_affiliation(id, community_id, nuid: nil, on_behalf_of: nil)
140
- AtlasRb::Mash.new(JSON.parse(
156
+ AtlasRb::Mash.new(write_resource(
141
157
  connection({}, nuid, on_behalf_of: on_behalf_of)
142
- .delete(ROUTE + id + "/affiliations/" + community_id)&.body
158
+ .delete(ROUTE + id + "/affiliations/" + community_id)
143
159
  ))["person"]
144
160
  end
145
161
  end
@@ -368,5 +368,70 @@ module AtlasRb
368
368
  JSON.parse(resp.body)
369
369
  end
370
370
  private_class_method :fetch_resource
371
+
372
+ # Shared write path behind the resource `POST` / `PATCH` / `DELETE`
373
+ # bindings: check the status, then parse. The companion to
374
+ # {fetch_resource}, and it exists for the same reason — Atlas answers a
375
+ # write aimed at a resource that isn't there with `head :not_found`, whose
376
+ # body is empty, so a bare `JSON.parse(resp.body)` raised
377
+ # `JSON::ParserError: unexpected end of input`. An operator loading a
378
+ # manifest of PIDs from another environment saw that, and nothing naming
379
+ # the missing object.
380
+ #
381
+ # Mapping:
382
+ #
383
+ # - `404` → {AtlasRb::NotFoundError}, naming the verb and path.
384
+ # - `410` → the parsed JSON body, as on the read path. An
385
+ # Idempotency-Key replay whose resource has since been tombstoned answers
386
+ # `410 Gone` WITH the tombstone, so it is a returnable body carrying
387
+ # `tombstoned` / `tombstoned_at` / `tombstoned_by`, not an error envelope.
388
+ # - any other non-2xx → {AtlasRb::ResourceError} carrying Atlas's status +
389
+ # body, so the failure is attributable at the boundary.
390
+ # - `2xx` → the parsed JSON body, for the caller to unwrap.
391
+ #
392
+ # Note the deliberate asymmetry with {fetch_resource}, which returns `nil`
393
+ # on a `404`: "there is nothing there" is a legitimate answer to a read,
394
+ # but not to a write. The caller asked for a change and did not get one, so
395
+ # `nil` would invite exactly the silent failure the read guard was written
396
+ # to prevent.
397
+ #
398
+ # The typed `403` / `422` translations ({AtlasRb::ForbiddenError},
399
+ # {AtlasRb::ReparentError}, and their siblings) fire in
400
+ # {Middleware::RaiseOnResourceError} while the request is still in the
401
+ # Faraday stack, so they raise before a binding reaches this — and a `422`
402
+ # the middleware deliberately passes through (`tombstone`'s
403
+ # `has_live_children`) reaches its caller as before, because the tombstone
404
+ # bindings return the raw response and never parse.
405
+ #
406
+ # @param resp [Faraday::Response] the completed write response.
407
+ # @return [Hash, Array] the parsed JSON body (incl. a `410` tombstone).
408
+ # @raise [AtlasRb::NotFoundError] on a `404`.
409
+ # @raise [AtlasRb::ResourceError] on any other non-2xx except `410`.
410
+ # @api private
411
+ def self.write_resource(resp)
412
+ if resp.status == 404
413
+ raise AtlasRb::NotFoundError.new("#{write_target(resp)} → 404 (no such resource)", response: resp)
414
+ end
415
+
416
+ unless resp.success? || resp.status == 410
417
+ raise AtlasRb::ResourceError.new("#{write_target(resp)} → #{resp.status}: #{resp.body}", response: resp)
418
+ end
419
+
420
+ JSON.parse(resp.body)
421
+ end
422
+ private_class_method :write_resource
423
+
424
+ # The verb and path of a completed request — `"PATCH /works/abc123"` — so a
425
+ # {write_resource} failure names what did not happen. Read off the response
426
+ # rather than passed in, keeping every write binding a plain wrap.
427
+ #
428
+ # @param resp [Faraday::Response] the completed response.
429
+ # @return [String] the verb and path.
430
+ # @api private
431
+ def self.write_target(resp)
432
+ env = resp.env
433
+ "#{env&.method.to_s.upcase} #{env&.url&.path}"
434
+ end
435
+ private_class_method :write_target
371
436
  end
372
437
  end
data/lib/atlas_rb/work.rb CHANGED
@@ -103,6 +103,10 @@ module AtlasRb
103
103
  # setter on the update surface.
104
104
  # @return [Hash] the created Work payload (post-update if `xml_path` was
105
105
  # supplied).
106
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
107
+ # resource, so the write did not happen.
108
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
109
+ # and body.
106
110
  #
107
111
  # @example Empty work, metadata to be added later
108
112
  # AtlasRb::Work.create("col-456")
@@ -120,9 +124,9 @@ module AtlasRb
120
124
  on_behalf_of: nil, depositor: nil)
121
125
  params = { collection_id: id }
122
126
  params[:depositor] = depositor if depositor
123
- result = AtlasRb::Mash.new(JSON.parse(
127
+ result = AtlasRb::Mash.new(write_resource(
124
128
  connection(params, nuid,
125
- on_behalf_of: on_behalf_of, idempotency_key: idempotency_key).post(ROUTE)&.body
129
+ on_behalf_of: on_behalf_of, idempotency_key: idempotency_key).post(ROUTE)
126
130
  ))["work"]
127
131
  return result if xml_path.to_s.empty?
128
132
 
@@ -163,13 +167,17 @@ module AtlasRb
163
167
  # envelope's `error` code is exposed as `#code`.
164
168
  # @raise [AtlasRb::ForbiddenError] if Atlas refuses the move on
165
169
  # authorization grounds (HTTP 403).
170
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
171
+ # resource, so the write did not happen.
172
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
173
+ # and body.
166
174
  #
167
175
  # @example
168
176
  # AtlasRb::Work.reparent("w-789", "col-999")
169
177
  def self.reparent(id, new_collection_id, nuid: nil, on_behalf_of: nil)
170
- AtlasRb::Mash.new(JSON.parse(
178
+ AtlasRb::Mash.new(write_resource(
171
179
  connection({ parent_id: new_collection_id }, nuid, on_behalf_of: on_behalf_of)
172
- .patch(ROUTE + id + '/parent')&.body
180
+ .patch(ROUTE + id + '/parent')
173
181
  ))["work"]
174
182
  end
175
183
 
@@ -235,6 +243,10 @@ module AtlasRb
235
243
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
236
244
  # omitted.
237
245
  # @return [Hash] the parsed JSON response from the patch.
246
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
247
+ # resource, so the write did not happen.
248
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
249
+ # and body.
238
250
  #
239
251
  # @example
240
252
  # AtlasRb::Work.update("w-789", "/tmp/work-mods.xml")
@@ -242,8 +254,8 @@ module AtlasRb
242
254
  payload = { binary: Faraday::Multipart::FilePart.new(File.open(xml_path),
243
255
  "application/xml",
244
256
  File.basename(xml_path)) }
245
- AtlasRb::Mash.new(JSON.parse(
246
- multipart(nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id, payload)&.body
257
+ AtlasRb::Mash.new(write_resource(
258
+ multipart(nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id, payload)
247
259
  ))
248
260
  end
249
261
 
@@ -264,12 +276,16 @@ module AtlasRb
264
276
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
265
277
  # omitted.
266
278
  # @return [Hash] the parsed JSON response.
279
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
280
+ # resource, so the write did not happen.
281
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
282
+ # and body.
267
283
  #
268
284
  # @example
269
285
  # AtlasRb::Work.metadata("w-789", title: "Revised Title")
270
286
  def self.metadata(id, values, nuid: nil, on_behalf_of: nil)
271
- AtlasRb::Mash.new(JSON.parse(
272
- connection({ metadata: values }, nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id)&.body
287
+ AtlasRb::Mash.new(write_resource(
288
+ connection({ metadata: values }, nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id)
273
289
  ))
274
290
  end
275
291
 
@@ -293,6 +309,10 @@ module AtlasRb
293
309
  # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
294
310
  # conflict that exhausted its internal retry budget (HTTP 409 with
295
311
  # `error: "stale_resource"`).
312
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
313
+ # resource, so the write did not happen.
314
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
315
+ # and body.
296
316
  #
297
317
  # @example
298
318
  # AtlasRb::Work.set_thumbnails(
@@ -303,9 +323,9 @@ module AtlasRb
303
323
  # )
304
324
  def self.set_thumbnails(id, thumbnail: nil, thumbnail_2x: nil, preview: nil, nuid: nil, on_behalf_of: nil)
305
325
  body = { thumbnail: thumbnail, thumbnail_2x: thumbnail_2x, preview: preview }.compact
306
- AtlasRb::Mash.new(JSON.parse(
326
+ AtlasRb::Mash.new(write_resource(
307
327
  connection({}, nuid, on_behalf_of: on_behalf_of)
308
- .patch(ROUTE + id + '/thumbnails', JSON.dump(body))&.body
328
+ .patch(ROUTE + id + '/thumbnails', JSON.dump(body))
309
329
  ))
310
330
  end
311
331
 
@@ -329,6 +349,10 @@ module AtlasRb
329
349
  # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
330
350
  # conflict that exhausted its internal retry budget (HTTP 409 with
331
351
  # `error: "stale_resource"`).
352
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
353
+ # resource, so the write did not happen.
354
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
355
+ # and body.
332
356
  #
333
357
  # @example
334
358
  # AtlasRb::Work.set_image_derivatives(
@@ -339,9 +363,9 @@ module AtlasRb
339
363
  # )
340
364
  def self.set_image_derivatives(id, small: nil, medium: nil, large: nil, nuid: nil, on_behalf_of: nil)
341
365
  body = { small: small, medium: medium, large: large }.compact
342
- AtlasRb::Mash.new(JSON.parse(
366
+ AtlasRb::Mash.new(write_resource(
343
367
  connection({}, nuid, on_behalf_of: on_behalf_of)
344
- .patch(ROUTE + id + '/image_derivatives', JSON.dump(body))&.body
368
+ .patch(ROUTE + id + '/image_derivatives', JSON.dump(body))
345
369
  ))
346
370
  end
347
371
 
@@ -386,6 +410,10 @@ module AtlasRb
386
410
  # (422) — `tier_exceeds_resource` / `tier_ordering_violation` /
387
411
  # `unknown_tier` (see {#code}).
388
412
  # @raise [AtlasRb::StaleResourceError] on an optimistic-lock conflict (409).
413
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
414
+ # resource, so the write did not happen.
415
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
416
+ # and body.
389
417
  #
390
418
  # @example
391
419
  # AtlasRb::Work.set_derivative_permissions(
@@ -395,9 +423,9 @@ module AtlasRb
395
423
  # service: ["northeastern:drs:repository:archives"] }
396
424
  # )
397
425
  def self.set_derivative_permissions(id, policy:, nuid: nil, on_behalf_of: nil)
398
- AtlasRb::Mash.new(JSON.parse(
426
+ AtlasRb::Mash.new(write_resource(
399
427
  connection({}, nuid, on_behalf_of: on_behalf_of)
400
- .patch(ROUTE + id + '/derivative_permissions', JSON.dump(policy))&.body
428
+ .patch(ROUTE + id + '/derivative_permissions', JSON.dump(policy))
401
429
  ))
402
430
  end
403
431
 
@@ -427,13 +455,17 @@ module AtlasRb
427
455
  # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
428
456
  # conflict that exhausted its internal retry budget (HTTP 409 with
429
457
  # `error: "stale_resource"`).
458
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
459
+ # resource, so the write did not happen.
460
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
461
+ # and body.
430
462
  #
431
463
  # @example
432
464
  # AtlasRb::Work.set_full_text("w-789", text: extracted_pdf_text)
433
465
  def self.set_full_text(id, text:, nuid: nil, on_behalf_of: nil)
434
- AtlasRb::Mash.new(JSON.parse(
466
+ AtlasRb::Mash.new(write_resource(
435
467
  connection({}, nuid, on_behalf_of: on_behalf_of)
436
- .patch(ROUTE + id + '/full_text', JSON.dump(text: text))&.body
468
+ .patch(ROUTE + id + '/full_text', JSON.dump(text: text))
437
469
  ))
438
470
  end
439
471
 
@@ -620,14 +652,18 @@ module AtlasRb
620
652
  # as `#code`.
621
653
  # @raise [AtlasRb::ForbiddenError] if Atlas refuses the link on
622
654
  # authorization grounds (HTTP 403).
655
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
656
+ # resource, so the write did not happen.
657
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
658
+ # and body.
623
659
  #
624
660
  # @example
625
661
  # AtlasRb::Work.add_linked_member("w-789", "col-456")
626
662
  # # => ["col-456"]
627
663
  def self.add_linked_member(work_id, collection_id, nuid: nil, on_behalf_of: nil)
628
- JSON.parse(
664
+ write_resource(
629
665
  connection({ collection_id: collection_id }, nuid, on_behalf_of: on_behalf_of)
630
- .post(ROUTE + work_id + '/linked_members')&.body
666
+ .post(ROUTE + work_id + '/linked_members')
631
667
  )
632
668
  end
633
669
 
@@ -658,14 +694,18 @@ module AtlasRb
658
694
  # as `#code`.
659
695
  # @raise [AtlasRb::ForbiddenError] if Atlas refuses the removal on
660
696
  # authorization grounds (HTTP 403).
697
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
698
+ # resource, so the write did not happen.
699
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
700
+ # and body.
661
701
  #
662
702
  # @example
663
703
  # AtlasRb::Work.remove_linked_member("w-789", "col-456")
664
704
  # # => []
665
705
  def self.remove_linked_member(work_id, collection_id, nuid: nil, on_behalf_of: nil)
666
- JSON.parse(
706
+ write_resource(
667
707
  connection({}, nuid, on_behalf_of: on_behalf_of)
668
- .delete(ROUTE + work_id + '/linked_members/' + collection_id)&.body
708
+ .delete(ROUTE + work_id + '/linked_members/' + collection_id)
669
709
  )
670
710
  end
671
711
  end
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.9.3
4
+ version: 1.9.4
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-07-31 00:00:00.000000000 Z
11
+ date: 2026-08-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday