atlas_rb 1.15.0 → 1.16.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: 8a32e3df39d1a5f9648e47a42b88be5deff1b28dc696f9575a073baac4b9fa58
4
- data.tar.gz: cb224b216444752165a39f242120e478cbe03c4a2613e36b19665d1897ec59b8
3
+ metadata.gz: ed32d60a2b3c2a562ee64d095a266c6b45848122ce4d75a0201c6736aeb52c2f
4
+ data.tar.gz: 229e5722c7b2036e490df5c06a5ad7afe496164fe7105a2ee232b7cb64f5d970
5
5
  SHA512:
6
- metadata.gz: 43cf01a77168ae4ae78de02bf26403e9c116b63f18d685adc7bf82275124aaa498129ea1a8dad9c4867330b455c236f68f20b05763743be8b4db97ceef0b8324
7
- data.tar.gz: 6fb09278babb33a465f090e32b0616649bddd4fb043337cbeace8c6c0b7f83ff0cde6710f01eecec69e91d8c65db2d58c67683a7c55496078f090312d7f77317
6
+ metadata.gz: e63d081a6b2f67297e95e2feb361ed232e585b3c22a84b68c0d35dd0a816aae1cfca0106613ddc9f097644bd8b02a098285ce48f7ee930126102037c2dff808c
7
+ data.tar.gz: 70931eed6c2ceaf7fd901a07005df4dec7570a5f90649b0bfb277826f927922bfb3ad30f73a226254d8059fb1c82f4e114e11a4a7977ddd1ff7d7e291e846d8f
data/.version CHANGED
@@ -1 +1 @@
1
- 1.15.0
1
+ 1.16.0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,183 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.0
4
+
5
+ ### Fixed — every read binding consults the HTTP status before it reads the body
6
+
7
+ Twenty-nine read bindings parsed or returned a response body without checking
8
+ the status. `Resource.fetch_resource` had enforced that contract for the typed
9
+ single-resource `find`s since 1.8.3, but a binding had to opt in by routing
10
+ through it, and the rest of the read surface never did. Four failure shapes
11
+ came of it:
12
+
13
+ | Binding shape | What the caller got on an error response |
14
+ |---|---|
15
+ | `JSON.parse(body).map { Mash.new(...) }` | `NoMethodError: undefined method 'each_pair' for [...]:Array` — the error envelope iterated as pairs |
16
+ | `JSON.parse(body)["key"].map` | `NoMethodError ... for nil:NilClass`, raised far from the cause |
17
+ | `Mash.new(JSON.parse(body))` | **no error at all** — the envelope came back as if it were data |
18
+ | raw `resp.body` (the `mods` bindings) | **no error at all** — the error text came back as a `String`, ready to be rendered |
19
+ | any of the above, non-JSON body | `JSON::ParserError`, naming neither the status nor the endpoint |
20
+
21
+ Three of those were worse than a wrong error class:
22
+
23
+ - **`Work.mods`** (and `Collection.mods`, `Community.mods`, `Resource.mods`,
24
+ `Resource.mods_version`) returned Atlas's error body as a `String`. Atlas
25
+ renders MODS to HTML server-side, so a consumer marks that body HTML-safe
26
+ and writes it into its own page. Pointing `ATLAS_URL` at another service in
27
+ the stack produced that service's stack trace by this route.
28
+ - **`Maintenance.read`** failed open. A `401` parsed into a `Mash` with no
29
+ `read_only` key, so a host read "no maintenance window" precisely when it
30
+ could not read the flag — the opposite of the endpoint's contract, which is
31
+ that a client unable to read the window cannot honour it.
32
+ - **`Blob.version_content`** discarded the status entirely, so an error body
33
+ became the bytes of the file an admin had just downloaded.
34
+
35
+ The fix is one Faraday middleware plus one shared binding-level guard, rather
36
+ than twenty-nine near-identical call-site edits:
37
+
38
+ - **`Middleware::RaiseOnReadError`** raises `AtlasRb::ResourceError` on a
39
+ non-2xx read, at the transport, where a binding cannot forget it. Registered
40
+ on the `:json` and `:system` connections, ahead of the typed translators so
41
+ their `on_complete` still runs first — a maintenance `503` stays a
42
+ `ReadOnlyModeError`, a refused re-parent stays a `ForbiddenError`.
43
+ - **`FaradayHelper#read_body` / `#read_raw`** apply the same mapping where a
44
+ binding can see it, which keeps the guarantee true for a caller who has
45
+ stubbed the transport. Every read binding now funnels through one of them,
46
+ and `Resource.fetch_resource` delegates to `read_body`.
47
+
48
+ The mapping, uniform across the read surface:
49
+
50
+ | Atlas answers | Read binding |
51
+ |---|---|
52
+ | `2xx` | the parsed body (or the raw body, for `mods`) |
53
+ | `404` | `nil` |
54
+ | `410` | the tombstone body |
55
+ | anything else | raises `AtlasRb::ResourceError`, carrying the status, verb, path and body |
56
+
57
+ **Two behaviour changes to note when upgrading.**
58
+
59
+ `404` now returns `nil` where a binding used to raise `JSON::ParserError` on
60
+ the empty body, and list reads return `nil` rather than `[]` — so a caller can
61
+ tell "no such container" from "an empty container", which mean different
62
+ things to a UI. Callers that iterate a list read need `&.each` or a nil check.
63
+
64
+ `Blob.version_content` now returns `{ status:, headers: }`, matching
65
+ `Blob.content`. Its status cannot be raised on: chunks reach the caller as
66
+ they arrive, so by the time the status is known an error body would already
67
+ have been streamed. A caller passing those chunks to an HTTP response must
68
+ consult the status, or an Atlas error page becomes the downloaded file.
69
+
70
+ `Reset.clean` raises where the env gate that guards `GET /reset` is closed,
71
+ instead of returning the refusal body. A test suite that opens with a reset and
72
+ then carries on against un-wiped state fails somewhere else entirely, so this
73
+ one has to be loud.
74
+
75
+ `Authentication.login` and `.groups` no longer document
76
+ `@raise [JSON::ParserError]`. An auth failure is a `401` and now raises
77
+ `ResourceError`; a host rescuing `JSON::ParserError` as a stand-in for "the
78
+ read failed" can drop it.
79
+
80
+ The streaming reads (`Blob.content`, `Blob.version_content`) are exempt from
81
+ the middleware, which is keyed off `on_data` — see the middleware's note on
82
+ why raising after the chunks have gone out is too late to help.
83
+
84
+ ### Added — a request deadline and a bounded retry on every connection
85
+
86
+ `lib/` set no timeout anywhere, so `Net::HTTP`'s defaults applied: 60s open,
87
+ 60s read. Worse, the pooled adapter's `max_retries = 1` made `Net::HTTP`
88
+ replay an idempotent GET after a read timeout, so a hung Atlas held a single
89
+ `GET` for **120 seconds**. A consumer that fans out four reads per request
90
+ thread parks that thread — and four sockets from a sixteen-socket pool — for
91
+ that whole time, which turns one degraded backend into a front-end outage. No
92
+ host can fix this from outside: it can rescue what the gem raises, but it
93
+ cannot impose a deadline on a socket the gem owns.
94
+
95
+ Four new configuration slots, following the `connection_pool_size` pattern
96
+ (optional, read at connection-build time):
97
+
98
+ | Slot | Default | Applies to |
99
+ |---|---|---|
100
+ | `open_timeout` | `2` seconds | all three connection shapes |
101
+ | `read_timeout` | `10` seconds | `:json`, `:system` |
102
+ | `upload_read_timeout` | `nil` (uncapped) | `:multipart` |
103
+ | `read_retries` | `2` (three attempts) | `:json`, `:system` |
104
+
105
+ `read_timeout` is sized off the measured read path — the slowest single call
106
+ on a consumer's Work page was ~200ms and the whole eight-call page ~450ms — so
107
+ ten seconds is roughly fifty times the observed per-call cost. It is
108
+ `Net::HTTP`'s per-read deadline rather than a whole-response budget, so a
109
+ streaming download is unaffected as long as bytes keep arriving. Set a slot to
110
+ `false` to remove a deadline, or override one call:
111
+
112
+ ```ruby
113
+ connection({}, nuid).get(path) { |req| req.options.timeout = 120 }
114
+ ```
115
+
116
+ The multipart shape takes no read deadline and no retry: a multi-gigabyte
117
+ upload legitimately outlives any page-sized budget, and a partially-streamed
118
+ `POST` must not be replayed blindly — even under an `Idempotency-Key`, which
119
+ the transport cannot see.
120
+
121
+ **Retries now live in exactly one layer.** `faraday-retry ~> 2.4` is a new
122
+ runtime dependency, registered as the outermost handler on the `:json` and
123
+ `:system` connections, and `Net::HTTP`'s own `max_retries` drops from `1` to
124
+ `0`. The 1.15.0 reasoning for that `1` was sound — a server that closed an
125
+ idle pooled socket errors on the next write, and a replay is what turns that
126
+ into a reconnect — and the middleware covers the same case better:
127
+ `EOFError`, `Errno::ECONNRESET` and `Faraday::ConnectionFailed` are all in its
128
+ exception list, now with jittered backoff, a bounded attempt count, and a
129
+ `request.atlas_rb` notification per attempt instead of a silent replay.
130
+ Stacking both layers would have given six timeout waits per call.
131
+
132
+ The policy is deliberately narrow:
133
+
134
+ - **Exceptions only** (`retry_statuses: []`). A response Atlas actually sent
135
+ is never retried. The maintenance `503` is why: its `Retry-After` is
136
+ measured in minutes, so an in-band retry would ignore it and hammer the
137
+ window, and `ReadOnlyModeError` must reach the caller on the first response.
138
+ - **`GET` / `HEAD` / `OPTIONS` only**, narrower than HTTP idempotency. `PUT`
139
+ and `DELETE` are idempotent in the spec, but Atlas's `DELETE` purges an OCFL
140
+ object and its `PATCH`/`PUT` writes carry optimistic-lock semantics. A
141
+ replay there should be a call site's decision.
142
+ - **`Faraday::ConnectionFailed` added explicitly.** It is not in the
143
+ middleware's own default list, and it is the failure that matters most here:
144
+ an Atlas restart mid-page-load is a refused connect, which one retry hides
145
+ completely.
146
+
147
+ Worst case per `GET` is now three attempts of ten seconds plus backoff, about
148
+ **30 seconds**, down from 120.
149
+
150
+ ## 1.15.1
151
+
152
+ ### Fixed — `Resource.permissions` no longer coerces a refused read to `nil`
153
+
154
+ Atlas gates `GET /resources/:id/permissions` on the caller's read right over
155
+ the resource itself, because the envelope names the Grouper groups and the
156
+ depositor's NUID. A caller who may not read the resource gets a real `403`
157
+ carrying `{ "error", "action", "subject" }`.
158
+
159
+ The binding parsed that body without consulting the status. The error envelope
160
+ has no `"resource"` key, so `JSON.parse(body)["resource"]` returned the same
161
+ `nil` an unknown id returns — the status, the message and the distinction were
162
+ all gone before the caller saw anything. A host reading this to drive its own
163
+ gate rendered "not found" for a resource the reader was merely not allowed to
164
+ see.
165
+
166
+ `permissions` now routes through `fetch_resource`, the guarded read path every
167
+ typed `find` already uses:
168
+
169
+ | Atlas answers | `permissions` |
170
+ |---|---|
171
+ | `200` | the ACL `Mash` |
172
+ | `404` | `nil` — unchanged |
173
+ | `403` | raises `AtlasRb::ResourceError`, `status == 403` |
174
+
175
+ A host that wants the old "nil for anything unreadable" behaviour rescues
176
+ `AtlasRb::ResourceError` and returns `nil` itself.
177
+
178
+ The YARD example was also wrong: the envelope carries `type`, `depositor`,
179
+ `proxy_uploader`, `edit_users`, `read`, `edit` and `embargo`, never `id`.
180
+
3
181
  ## 1.15.0
4
182
 
5
183
  ### Changed — the transport reuses connections instead of opening one per request
data/Gemfile.lock CHANGED
@@ -1,11 +1,12 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- atlas_rb (1.15.0)
4
+ atlas_rb (1.16.0)
5
5
  faraday (~> 2.7)
6
6
  faraday-follow_redirects (~> 0.3.0)
7
7
  faraday-multipart (~> 1)
8
8
  faraday-net_http_persistent (~> 2.3)
9
+ faraday-retry (~> 2.4)
9
10
  hashie (~> 5.0)
10
11
  jwt (~> 2.7)
11
12
 
@@ -28,9 +29,11 @@ GEM
28
29
  faraday-net_http_persistent (2.3.1)
29
30
  faraday (~> 2.5)
30
31
  net-http-persistent (>= 4.0.4, < 5)
32
+ faraday-retry (2.4.0)
33
+ faraday (~> 2.0)
31
34
  hashie (5.1.0)
32
35
  logger
33
- json (2.21.2)
36
+ json (3.0.2)
34
37
  jwt (2.10.3)
35
38
  base64
36
39
  logger (1.7.0)
data/README.md CHANGED
@@ -192,6 +192,62 @@ pool between examples, or a socket from one example stays open into the next:
192
192
  config.after(:each, :atlas_rb_server) { AtlasRb::Transport.reset_connections! }
193
193
  ```
194
194
 
195
+ ### Deadlines and retries
196
+
197
+ Every connection the gem builds carries a deadline, because a host cannot
198
+ retrofit one: it can rescue what the gem raises, but it cannot impose a
199
+ timeout on a socket the gem owns. Left to `Net::HTTP`'s defaults a hung Atlas
200
+ held a single `GET` for two minutes, which parks a request thread and its
201
+ share of the socket pool for that whole time.
202
+
203
+ Four knobs, all optional:
204
+
205
+ ```ruby
206
+ AtlasRb.configure do |config|
207
+ # Seconds to wait for a socket to open. Defaults to 2 — Atlas is on the same
208
+ # host or the same overlay network, so a healthy connect is sub-millisecond.
209
+ config.open_timeout = 2
210
+
211
+ # Seconds to wait for a response on the JSON and system connections.
212
+ # Defaults to 10. This is a per-socket-read deadline, not a whole-response
213
+ # budget, so a streaming download is fine as long as bytes keep arriving.
214
+ config.read_timeout = 10
215
+
216
+ # The same, for binary uploads. Defaults to nil — uncapped, because a
217
+ # multi-gigabyte upload has no defensible cap and those calls run in jobs.
218
+ config.upload_read_timeout = nil
219
+
220
+ # Extra attempts an idempotent read gets after a transport failure, so 2
221
+ # means three attempts in all. Defaults to 2; set 0 to disable.
222
+ config.read_retries = 2
223
+ end
224
+ ```
225
+
226
+ Set a timeout slot to `false` to remove the deadline rather than change it.
227
+ All four are read when a connection is first built, so set them before the
228
+ first Atlas call.
229
+
230
+ A single call that legitimately outlives the budget overrides it on the
231
+ request — the block the transport forwards runs last, so it wins:
232
+
233
+ ```ruby
234
+ # A bulk export paging a large Collection, or a metadata dump:
235
+ AtlasRb::Work.connection({}, nuid).get("/resources/#{id}/descendant_works") do |req|
236
+ req.options.timeout = 120
237
+ end
238
+ ```
239
+
240
+ Only reads are retried, and only when the request failed in transport — a
241
+ refused or reset connection, or a timeout. A response Atlas actually sent is
242
+ never retried, which is what keeps the maintenance `503` honest: its
243
+ `Retry-After` is measured in minutes, so `ReadOnlyModeError` reaches you on
244
+ the first response instead of the gem waiting in band. Writes are never
245
+ retried; a `POST` that may have landed is the call site's decision, not the
246
+ transport's.
247
+
248
+ Each attempt emits its own `request.atlas_rb` notification, so a host counting
249
+ round-trips sees a retry as the second round-trip it really is.
250
+
195
251
  ## Resource hierarchy
196
252
 
197
253
  ```
@@ -507,6 +563,38 @@ question, and callers already nil-check a `find`:
507
563
  AtlasRb::Work.find("doesnotexist") # => nil
508
564
  ```
509
565
 
566
+ That holds across the whole read surface, not just `find`. A **list** read
567
+ returns `nil` too, rather than an empty array, so you can tell "no such
568
+ container" from "an empty container" — two answers that mean different things
569
+ to a UI:
570
+
571
+ ```ruby
572
+ AtlasRb::Work.assets("doesnotexist") # => nil
573
+ AtlasRb::Work.assets(real_work_noid) # => [] for a Work with no assets
574
+ ```
575
+
576
+ Anything else a read gets back raises `AtlasRb::ResourceError`, which carries
577
+ the status, the endpoint and Atlas's body. That is the point: the failure is
578
+ attributable where it happened, instead of surfacing as a `NoMethodError` on
579
+ `nil` several frames later, or — for the `mods` bindings, which pass Atlas's
580
+ rendered body through by design — as an error page returned to you as a
581
+ `String` and rendered:
582
+
583
+ ```ruby
584
+ begin
585
+ AtlasRb::Work.mods(noid, "html")
586
+ rescue AtlasRb::ResourceError => e
587
+ e.status # => 403
588
+ e.message # => "GET /works/9zw3s1h/mods.html → 403: {\"error\":\"forbidden\",…}"
589
+ end
590
+ ```
591
+
592
+ A `410 Gone` is returned rather than raised, the same way `find` returns a
593
+ tombstone. And the two streaming reads (`Blob.content`,
594
+ `Blob.version_content`) hand you `{ status:, headers: }` instead of raising,
595
+ because their chunks reach you as they arrive — check the status before you
596
+ treat those bytes as a file.
597
+
510
598
  A **write** raises `AtlasRb::NotFoundError`. The caller asked for a change and
511
599
  did not get one, so `nil` would invite the silent failure the read guard was
512
600
  written to prevent:
@@ -25,16 +25,19 @@ module AtlasRb
25
25
  # @param nuid [String] the user's Northeastern University ID.
26
26
  # @param email [String, nil] optional account email to act as (the `acct`
27
27
  # selector); nil resolves the preferred account.
28
- # @return [Hash] the user record returned by `GET /user`, including at
29
- # minimum `"id"`, `"name"`, and `"groups"`.
30
- # @raise [JSON::ParserError] if the response body is not valid JSON
31
- # (typically caused by an auth failure returning HTML).
28
+ # @return [AtlasRb::Mash, nil] the user record returned by `GET /user`,
29
+ # including at minimum `"id"`, `"name"`, and `"groups"`.
30
+ # `nil` when Atlas answers `404` nothing is there to read, or, with a
31
+ # misconfigured `ATLAS_URL`, the route is not Atlas's at all.
32
+ # @raise [AtlasRb::ResourceError] on any non-2xx other than `404` / `410`
33
+ # (an auth or validation envelope, a `5xx`, a proxy's `503`), carrying
34
+ # Atlas's status and body so the failure is attributable at the boundary.
32
35
  #
33
36
  # @example
34
37
  # AtlasRb::Authentication.login("001234567")
35
38
  # # => { "id" => 42, "name" => "Jane Doe", "groups" => [...] }
36
39
  def self.login(nuid, email: nil)
37
- AtlasRb::Mash.new(JSON.parse(connection({}, nuid, account: email).get('/user')&.body))
40
+ read_body(connection({}, nuid, account: email).get('/user')) { |body| AtlasRb::Mash.new(body) }
38
41
  end
39
42
 
40
43
  # Fetch only the group memberships for an NUID.
@@ -43,8 +46,12 @@ module AtlasRb
43
46
  # when authorization checks only need group names.
44
47
  #
45
48
  # @param nuid [String] the user's Northeastern University ID.
46
- # @return [Array<Hash>] the `"groups"` array from the user record.
47
- # @raise [JSON::ParserError] if the response body is not valid JSON.
49
+ # @return [Array<Hash>, nil] the `"groups"` array from the user record.
50
+ # `nil` when Atlas answers `404` nothing is there to read, or, with a
51
+ # misconfigured `ATLAS_URL`, the route is not Atlas's at all.
52
+ # @raise [AtlasRb::ResourceError] on any non-2xx other than `404` / `410`
53
+ # (an auth or validation envelope, a `5xx`, a proxy's `503`), carrying
54
+ # Atlas's status and body so the failure is attributable at the boundary.
48
55
  #
49
56
  # @example
50
57
  # AtlasRb::Authentication.groups("001234567")
@@ -54,7 +61,7 @@ module AtlasRb
54
61
  # token = user_details[:token] ...
55
62
  # TODO - need to update atlas login to give back name, id, and token upon logging in
56
63
  # result = JSON.parse(connection({ token: token }).post('/users/2/groups')&.body)["user"]["groups"]
57
- AtlasRb::Mash.new(JSON.parse(connection({}, nuid).get('/user')&.body))["groups"]
64
+ read_body(connection({}, nuid).get('/user')) { |body| AtlasRb::Mash.new(body)["groups"] }
58
65
  end
59
66
  end
60
67
  end
data/lib/atlas_rb/blob.rb CHANGED
@@ -57,16 +57,21 @@ module AtlasRb
57
57
  # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
58
58
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
59
59
  # omitted.
60
- # @return [AtlasRb::Mash] `{ "file_set" => "<noid>", "work" => "<noid>" }`
60
+ # @return [AtlasRb::Mash, nil] `{ "file_set" => "<noid>", "work" => "<noid>" }`
61
61
  # (either value `nil` when unresolvable).
62
62
  #
63
+ # `nil` when Atlas answers `404` — nothing is there to read, or, with a
64
+ # misconfigured `ATLAS_URL`, the route is not Atlas's at all.
65
+ # @raise [AtlasRb::ResourceError] on any non-2xx other than `404` / `410`
66
+ # (an auth or validation envelope, a `5xx`, a proxy's `503`), carrying
67
+ # Atlas's status and body so the failure is attributable at the boundary.
63
68
  # @example
64
69
  # AtlasRb::Blob.ancestry("b-321")
65
70
  # # => { "file_set" => "fs-654", "work" => "w-789" }
66
71
  def self.ancestry(id, nuid: nil, on_behalf_of: nil)
67
- AtlasRb::Mash.new(JSON.parse(
68
- connection({}, nuid, on_behalf_of: on_behalf_of).get("#{ROUTE}#{id}/ancestry")&.body
69
- ))
72
+ read_body(connection({}, nuid, on_behalf_of: on_behalf_of).get("#{ROUTE}#{id}/ancestry")) do |body|
73
+ AtlasRb::Mash.new(body)
74
+ end
70
75
  end
71
76
 
72
77
  # Convenience over {.ancestry}: the containing Work's noid for a content
@@ -277,17 +282,22 @@ module AtlasRb
277
282
  # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
278
283
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
279
284
  # omitted.
280
- # @return [AtlasRb::Mash] the parsed envelope, with `"blob_id"` and a
285
+ # @return [AtlasRb::Mash, nil] the parsed envelope, with `"blob_id"` and a
281
286
  # `"versions"` array (reverse chronological).
282
287
  #
288
+ # `nil` when Atlas answers `404` — nothing is there to read, or, with a
289
+ # misconfigured `ATLAS_URL`, the route is not Atlas's at all.
290
+ # @raise [AtlasRb::ResourceError] on any non-2xx other than `404` / `410`
291
+ # (an auth or validation envelope, a `5xx`, a proxy's `503`), carrying
292
+ # Atlas's status and body so the failure is attributable at the boundary.
283
293
  # @example
284
294
  # history = AtlasRb::Blob.versions("b-321")
285
295
  # history["versions"].first["version_id"] # => "v5"
286
296
  # history["versions"].first["digest"] # => "sha512:9f86d0…"
287
297
  def self.versions(id, nuid: nil, on_behalf_of: nil)
288
- AtlasRb::Mash.new(JSON.parse(
289
- connection({}, nuid, on_behalf_of: on_behalf_of).get("#{ROUTE}#{id}/versions")&.body
290
- ))
298
+ read_body(connection({}, nuid, on_behalf_of: on_behalf_of).get("#{ROUTE}#{id}/versions")) do |body|
299
+ AtlasRb::Mash.new(body)
300
+ end
291
301
  end
292
302
 
293
303
  # Read binary version history for many Blobs in one round-trip.
@@ -316,20 +326,25 @@ module AtlasRb
316
326
  # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
317
327
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
318
328
  # omitted.
319
- # @return [Array<AtlasRb::Mash>] one {.versions}-shaped envelope per resolved
329
+ # @return [Array<AtlasRb::Mash>, nil] one {.versions}-shaped envelope per resolved
320
330
  # Blob (`"blob_id"` plus a reverse-chronological `"versions"` array); empty
321
331
  # when none resolved.
322
332
  #
333
+ # `nil` when Atlas answers `404` — nothing is there to read, or, with a
334
+ # misconfigured `ATLAS_URL`, the route is not Atlas's at all.
335
+ # @raise [AtlasRb::ResourceError] on any non-2xx other than `404` / `410`
336
+ # (an auth or validation envelope, a `5xx`, a proxy's `503`), carrying
337
+ # Atlas's status and body so the failure is attributable at the boundary.
323
338
  # @example Render a Work's files with their histories in two calls
324
339
  # assets = AtlasRb::Work.assets(work_noid).reject { |a| a[:uri].present? }
325
340
  # history = AtlasRb::Blob.find_many_versions(assets.map(&:noid))
326
341
  # .index_by { |h| h["blob_id"] }
327
342
  # history[assets.first.noid]["versions"].first["revision"] # => 3
328
343
  def self.find_many_versions(ids, nuid: nil, on_behalf_of: nil)
329
- JSON.parse(
344
+ read_body(
330
345
  connection({}, nuid, on_behalf_of: on_behalf_of)
331
- .post("#{ROUTE}find_many_versions", JSON.dump(ids: Array(ids)))&.body
332
- ).map { |envelope| AtlasRb::Mash.new(envelope) }
346
+ .post("#{ROUTE}find_many_versions", JSON.dump(ids: Array(ids)))
347
+ ) { |body| body.map { |envelope| AtlasRb::Mash.new(envelope) } }
333
348
  end
334
349
 
335
350
  # Stream the bytes of a *prior* version of a Blob through a block.
@@ -353,7 +368,13 @@ module AtlasRb
353
368
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
354
369
  # omitted.
355
370
  # @yieldparam chunk [String] the next chunk of binary data.
356
- # @return [Hash] the response headers from the version-content request.
371
+ # @return [Hash] `{ status:, headers: }` the HTTP status and response
372
+ # headers. The status is handed back rather than raised on, because
373
+ # chunks reach the caller as they arrive: by the time the status could
374
+ # be checked, an error body would already have been streamed. A caller
375
+ # writing the chunks to an HTTP response (or a file) must consult it, or
376
+ # an Atlas error page becomes the bytes of the downloaded file. Matches
377
+ # {.content}'s contract.
357
378
  #
358
379
  # @example Download a superseded version to disk
359
380
  # File.open("/tmp/old.pdf", "wb") do |f|
@@ -361,13 +382,14 @@ module AtlasRb
361
382
  # end
362
383
  def self.version_content(id, version_id, nuid: nil, on_behalf_of: nil, &chunk_handler)
363
384
  headers = {}
364
- connection({}, nuid, on_behalf_of: on_behalf_of).get("#{ROUTE}#{id}/versions/#{version_id}/content") do |req|
385
+ response = connection({}, nuid, on_behalf_of: on_behalf_of)
386
+ .get("#{ROUTE}#{id}/versions/#{version_id}/content") do |req|
365
387
  req.options.on_data = proc do |chunk, _bytes_received, env|
366
388
  headers = env.response_headers if headers.empty? && env
367
389
  chunk_handler.call(chunk)
368
390
  end
369
391
  end
370
- headers
392
+ { status: response.status, headers: headers }
371
393
  end
372
394
 
373
395
  # Roll a Blob back to a prior version.
@@ -196,15 +196,18 @@ module AtlasRb
196
196
  # round-trip per child. For a whole subtree flattened to Works, use
197
197
  # {AtlasRb::Resource.descendant_works}.
198
198
  #
199
- # @return [Array<String>] child noids from `GET /collections/<id>/children`.
199
+ # @return [Array<String>, nil] child noids from `GET /collections/<id>/children`.
200
200
  #
201
+ # `nil` when Atlas answers `404` — nothing is there to read, or, with a
202
+ # misconfigured `ATLAS_URL`, the route is not Atlas's at all.
203
+ # @raise [AtlasRb::ResourceError] on any non-2xx other than `404` / `410`
204
+ # (an auth or validation envelope, a `5xx`, a proxy's `503`), carrying
205
+ # Atlas's status and body so the failure is attributable at the boundary.
201
206
  # @example
202
207
  # AtlasRb::Collection.children("col-456")
203
208
  # # => ["w-789", "w-790"]
204
209
  def self.children(id, nuid: nil, on_behalf_of: nil)
205
- JSON.parse(
206
- connection({}, nuid, on_behalf_of: on_behalf_of).get(ROUTE + id + '/children')&.body
207
- )
210
+ read_body(connection({}, nuid, on_behalf_of: on_behalf_of).get(ROUTE + id + '/children'))
208
211
  end
209
212
 
210
213
  # Replace a Collection's metadata by uploading a MODS XML document.
@@ -312,15 +315,20 @@ module AtlasRb
312
315
  # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
313
316
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
314
317
  # omitted.
315
- # @return [String] the raw response body in the requested format.
318
+ # @return [String, nil] the raw response body in the requested format.
316
319
  #
320
+ # `nil` when Atlas answers `404` — nothing is there to read, or, with a
321
+ # misconfigured `ATLAS_URL`, the route is not Atlas's at all.
322
+ # @raise [AtlasRb::ResourceError] on any non-2xx other than `404` / `410`
323
+ # (an auth or validation envelope, a `5xx`, a proxy's `503`), carrying
324
+ # Atlas's status and body so the failure is attributable at the boundary.
317
325
  # @example
318
326
  # AtlasRb::Collection.mods("col-456", "xml")
319
327
  def self.mods(id, kind = nil, nuid: nil, on_behalf_of: nil)
320
328
  # json default, html, xml
321
- connection({}, nuid, on_behalf_of: on_behalf_of).get(
322
- ROUTE + id + '/mods' + (kind.to_s.empty? ? '' : ".#{kind}")
323
- )&.body
329
+ read_raw(connection({}, nuid, on_behalf_of: on_behalf_of).get(
330
+ ROUTE + id + '/mods' + (kind.to_s.empty? ? '' : ".#{kind}")
331
+ ))
324
332
  end
325
333
  end
326
334
  end
@@ -175,15 +175,18 @@ module AtlasRb
175
175
  # round-trip per child. For a whole subtree flattened to Works, use
176
176
  # {AtlasRb::Resource.descendant_works}.
177
177
  #
178
- # @return [Array<String>] child noids from `GET /communities/<id>/children`.
178
+ # @return [Array<String>, nil] child noids from `GET /communities/<id>/children`.
179
179
  #
180
+ # `nil` when Atlas answers `404` — nothing is there to read, or, with a
181
+ # misconfigured `ATLAS_URL`, the route is not Atlas's at all.
182
+ # @raise [AtlasRb::ResourceError] on any non-2xx other than `404` / `410`
183
+ # (an auth or validation envelope, a `5xx`, a proxy's `503`), carrying
184
+ # Atlas's status and body so the failure is attributable at the boundary.
180
185
  # @example
181
186
  # AtlasRb::Community.children("c-123")
182
187
  # # => ["fn106x926", "kw52j804p"]
183
188
  def self.children(id, nuid: nil, on_behalf_of: nil)
184
- JSON.parse(
185
- connection({}, nuid, on_behalf_of: on_behalf_of).get(ROUTE + id + '/children')&.body
186
- )
189
+ read_body(connection({}, nuid, on_behalf_of: on_behalf_of).get(ROUTE + id + '/children'))
187
190
  end
188
191
 
189
192
  # Replace a Community's metadata by uploading a MODS XML document.
@@ -293,16 +296,21 @@ module AtlasRb
293
296
  # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
294
297
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
295
298
  # omitted.
296
- # @return [String] the raw response body (JSON, HTML, or XML serialized
299
+ # @return [String, nil] the raw response body (JSON, HTML, or XML serialized
297
300
  # as a string).
298
301
  #
302
+ # `nil` when Atlas answers `404` — nothing is there to read, or, with a
303
+ # misconfigured `ATLAS_URL`, the route is not Atlas's at all.
304
+ # @raise [AtlasRb::ResourceError] on any non-2xx other than `404` / `410`
305
+ # (an auth or validation envelope, a `5xx`, a proxy's `503`), carrying
306
+ # Atlas's status and body so the failure is attributable at the boundary.
299
307
  # @example HTML rendering for display
300
308
  # AtlasRb::Community.mods("c-123", "html")
301
309
  def self.mods(id, kind = nil, nuid: nil, on_behalf_of: nil)
302
310
  # json default, html, xml
303
- connection({}, nuid, on_behalf_of: on_behalf_of).get(
304
- ROUTE + id + '/mods' + (kind.to_s.empty? ? '' : ".#{kind}")
305
- )&.body
311
+ read_raw(connection({}, nuid, on_behalf_of: on_behalf_of).get(
312
+ ROUTE + id + '/mods' + (kind.to_s.empty? ? '' : ".#{kind}")
313
+ ))
306
314
  end
307
315
  end
308
316
  end
@@ -88,9 +88,14 @@ module AtlasRb
88
88
  # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
89
89
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
90
90
  # omitted.
91
- # @return [AtlasRb::Mash] `{ "compilations" => [...], "pagination" => {...} }`.
91
+ # @return [AtlasRb::Mash, nil] `{ "compilations" => [...], "pagination" => {...} }`.
92
92
  # Each entry in `"compilations"` is a flat Compilation, carrying the
93
93
  # same keys {.find} returns.
94
+ # `nil` when Atlas answers `404` — nothing is there to read, or, with a
95
+ # misconfigured `ATLAS_URL`, the route is not Atlas's at all.
96
+ # @raise [AtlasRb::ResourceError] on any non-2xx other than `404` / `410`
97
+ # (an auth or validation envelope, a `5xx`, a proxy's `503`), carrying
98
+ # Atlas's status and body so the failure is attributable at the boundary.
94
99
  # @raise [AtlasRb::ForbiddenError] on a cross-owner listing without admin.
95
100
  #
96
101
  # @example My Sets
@@ -114,9 +119,9 @@ module AtlasRb
114
119
  params[:q] = q if q
115
120
  params[:page] = page if page
116
121
  params[:per_page] = per_page if per_page
117
- AtlasRb::Mash.new(JSON.parse(
118
- connection(params, nuid, on_behalf_of: on_behalf_of).get(ROUTE)&.body
119
- ))
122
+ read_body(connection(params, nuid, on_behalf_of: on_behalf_of).get(ROUTE)) do |body|
123
+ AtlasRb::Mash.new(body)
124
+ end
120
125
  end
121
126
 
122
127
  # Create a Compilation owned by the acting user.
@@ -414,10 +419,15 @@ module AtlasRb
414
419
  # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
415
420
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
416
421
  # omitted.
417
- # @return [AtlasRb::Mash] `{ "contents" => [...], "pagination" =>
422
+ # @return [AtlasRb::Mash, nil] `{ "contents" => [...], "pagination" =>
418
423
  # { "total", "page", "per_page", "pages" } }`. Each entry is a
419
424
  # lightweight digest in the {Resource.find_many} vocabulary —
420
425
  # `id` / `noid` / `klass` / `title` / `thumbnail`.
426
+ # `nil` when Atlas answers `404` — nothing is there to read, or, with a
427
+ # misconfigured `ATLAS_URL`, the route is not Atlas's at all.
428
+ # @raise [AtlasRb::ResourceError] on any non-2xx other than `404` / `410`
429
+ # (an auth or validation envelope, a `5xx`, a proxy's `503`), carrying
430
+ # Atlas's status and body so the failure is attributable at the boundary.
421
431
  # @raise [AtlasRb::ForbiddenError] if the caller may not read this Set.
422
432
  #
423
433
  # @example
@@ -428,9 +438,9 @@ module AtlasRb
428
438
  params = {}
429
439
  params[:page] = page if page
430
440
  params[:per_page] = per_page if per_page
431
- AtlasRb::Mash.new(JSON.parse(
432
- connection(params, nuid, on_behalf_of: on_behalf_of).get(ROUTE + id + '/contents')&.body
433
- ))
441
+ read_body(connection(params, nuid, on_behalf_of: on_behalf_of).get(ROUTE + id + '/contents')) do |body|
442
+ AtlasRb::Mash.new(body)
443
+ end
434
444
  end
435
445
  end
436
446
  end