atlas_rb 1.29.0 → 1.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 992d6ea2f49be14d9163123932c11e9f150751ced8e3261ef08c532879177230
4
- data.tar.gz: 001b33d6eb73fb1b3cbcc2fc5e066ea0b1ccf5b0bae29455eafb1da0fd68036d
3
+ metadata.gz: 66d1cdefb1c1d3935fa8cd3721438b8b81b3bd89509317a11beefac39b55c9c9
4
+ data.tar.gz: d8b9bcea27ca0cb9ed3a40b64b31a944c0e6c22ba82aca5f32aa8f293cd16e6a
5
5
  SHA512:
6
- metadata.gz: c12109d7c7cda3ab19f6b41d1849c8085fd30b54a1a638a0848fcc039e46f5745b14686fbfab3a2df8390be10122278e3693dc16eb2f5f9f681383715e7ba203
7
- data.tar.gz: 0da824372eb26beccf7b0bbb5230a6b446bc0379142b7339b228bf1364580ac19a94f3021879cd9027270539e8ad4f9f0915a7a61fd8e7f1464e474445a7a479
6
+ metadata.gz: 4bf52d38cef2068881ee863d6f41fcd0d0149eab2714c1cec8d942f0f3f2ef422faeb52b92528579820e88149a5bf9613739e65091f9727df8f1151cdefd543f
7
+ data.tar.gz: dd88d74e36f72f186cbb5a0473b046cef6266b2fb5b45b48b2101db77e643df3d33cefa61048390134962fbbfadf537cb26759d964b1bbd9bf7ee97f84205721
data/.version CHANGED
@@ -1 +1 @@
1
- 1.29.0
1
+ 1.29.1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.29.1
4
+
5
+ Needs an Atlas that withholds the files beneath a tombstone (0.6.245 or later).
6
+ Against an older Atlas nothing raises, because that Atlas still serves them.
7
+
8
+ ### Added — `AtlasRb::TombstonedError`
9
+
10
+ Raised on Atlas's `410` with `error: "tombstoned"`: a file whose Blob, FileSet
11
+ or Work is tombstoned, or a tombstoned Work's file list. A `ResourceError`
12
+ subclass. `tombstoned_resource_id` and `tombstoned_resource_type` name the
13
+ resource whose tombstone applies, and `work_retracted?` is true when that is
14
+ the Work. `Work.assets`, `.file_sets` and `.file_revisions` raise it in place of
15
+ mapping over the error envelope as if it were the list. A `410` carrying the
16
+ resource's own body, as `find` gets, is still returned.
17
+
18
+ ### Changed — `Blob.content` and `Blob.version_content` yield only a 2xx body
19
+
20
+ Faraday hands every chunk to `on_data` whatever the status, so an error
21
+ envelope reached the block as file bytes. Now a chunk reaches the block only
22
+ for a `2xx`. A tombstone refusal raises `TombstonedError` before any chunk; any
23
+ other status is returned with no chunk yielded.
24
+
25
+ `Blob.find` documents `tombstoned_in_effect`, `tombstoned_resource_id` and
26
+ `tombstoned_resource_type`, and `Blob.ancestry` documents `file_set_tombstoned`
27
+ and `work_tombstoned`.
28
+
3
29
  ## 1.29.0
4
30
 
5
31
  Requires an Atlas that serves `GET /works/:id/tombstoned_assets` (0.6.244 or
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- atlas_rb (1.29.0)
4
+ atlas_rb (1.29.1)
5
5
  faraday (~> 2.7)
6
6
  faraday-follow_redirects (~> 0.3.0)
7
7
  faraday-multipart (~> 1)
data/README.md CHANGED
@@ -737,11 +737,32 @@ rescue AtlasRb::ResourceError => e
737
737
  end
738
738
  ```
739
739
 
740
- A `410 Gone` is returned rather than raised, the same way `find` returns a
741
- tombstone. And the two streaming reads (`Blob.content`,
742
- `Blob.version_content`) hand you `{ status:, headers: }` instead of raising,
743
- because their chunks reach you as they arrive — check the status before you
744
- treat those bytes as a file.
740
+ A `410 Gone` that carries the resource's own body is returned rather than
741
+ raised, the same way `find` returns a tombstone. The two streaming reads
742
+ (`Blob.content`, `Blob.version_content`) hand you `{ status:, headers: }`
743
+ instead of raising. Only a `2xx` body reaches your block, so an error envelope
744
+ never becomes the file; check the status to answer the request.
745
+
746
+ #### A tombstone withholds files
747
+
748
+ Atlas withholds a file when the Blob, its FileSet or its Work is tombstoned,
749
+ and a tombstoned Work's file lists. It answers `410` with `error:
750
+ "tombstoned"`, and the gem raises `AtlasRb::TombstonedError`, a
751
+ `ResourceError`. `Blob.content` raises it before your block sees a chunk, and
752
+ `Work.assets`, `.file_sets` and `.file_revisions` raise it in place of a list.
753
+ Admins and the devolved-admin tier are still served.
754
+
755
+ ```ruby
756
+ begin
757
+ AtlasRb::Blob.content(blob_id) { |chunk| response.stream.write(chunk) }
758
+ rescue AtlasRb::TombstonedError => e
759
+ e.tombstoned_resource_type # => "FileSet" (one file removed) or "Work"
760
+ e.work_retracted? # => true when the whole Work is tombstoned
761
+ end
762
+ ```
763
+
764
+ To refuse before streaming, read `Blob.find`: `tombstoned_in_effect` and
765
+ `tombstoned_resource_type` say the same thing. Needs Atlas 0.6.245 or later.
745
766
 
746
767
  A **write** raises `AtlasRb::NotFoundError`. The caller asked for a change and
747
768
  did not get one, so `nil` would invite the silent failure the read guard was
@@ -854,8 +875,9 @@ end
854
875
 
855
876
  It is not a `ResourceError`, which hosts treat as a refusal of the request.
856
877
 
857
- A streaming download (`Blob.content`, `Blob.version_content`) cannot raise
858
- this: its body has already gone to your block. Check the returned `status`.
878
+ A streaming download (`Blob.content`, `Blob.version_content`) does not raise
879
+ this. It returns the `status` and yields no chunk of the error body, so check
880
+ the status.
859
881
 
860
882
  ### Atlas's own latency
861
883
 
@@ -909,8 +931,8 @@ AtlasRb::Work.assets(work.id)
909
931
 
910
932
  # 4. Stream the binary back without buffering it in memory.
911
933
  File.open("out.pdf", "wb") do |f|
912
- headers = AtlasRb::Blob.content(blob.id) { |chunk| f.write(chunk) }
913
- puts headers["content-type"]
934
+ res = AtlasRb::Blob.content(blob.id) { |chunk| f.write(chunk) }
935
+ puts res[:headers]["content-type"]
914
936
  end
915
937
 
916
938
  # 5. Look up the acting user and their groups.
data/lib/atlas_rb/blob.rb CHANGED
@@ -29,6 +29,12 @@ module AtlasRb
29
29
  # held bytes — reconciliation compares this against the v1 manifest
30
30
  # without re-downloading), and a download URL — or `nil` when the Blob
31
31
  # does not exist (`404`).
32
+ #
33
+ # `"tombstoned_in_effect"` is true when the Blob, its FileSet or its Work
34
+ # is tombstoned, and `"tombstoned_resource_type"` says which (`"FileSet"`
35
+ # for a removed file, `"Work"` for a retracted Work). Atlas answers such a
36
+ # Blob `410` with this body, which is returned, so a host can refuse a
37
+ # download before it calls {.content}. Atlas 0.6.245 or later.
32
38
  # @raise [AtlasRb::ResourceError] on any non-2xx other than `404` / `410` (e.g. an
33
39
  # auth/validation error envelope), carrying Atlas's status + body.
34
40
  #
@@ -58,7 +64,9 @@ module AtlasRb
58
64
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
59
65
  # omitted.
60
66
  # @return [AtlasRb::Mash, nil] `{ "file_set" => "<noid>", "work" => "<noid>" }`
61
- # (either value `nil` when unresolvable).
67
+ # (either value `nil` when unresolvable), with `"file_set_tombstoned"` and
68
+ # `"work_tombstoned"` beside them (Atlas 0.6.245 or later; each `nil` with
69
+ # its noid).
62
70
  #
63
71
  # `nil` when Atlas answers `404` — nothing is there to read, or, with a
64
72
  # misconfigured `ATLAS_URL`, the route is not Atlas's at all.
@@ -114,6 +122,12 @@ module AtlasRb
114
122
  # @yieldparam chunk [String] the next chunk of binary data.
115
123
  # @return [Hash] `{ status: Integer, headers: Faraday::Utils::Headers }` for
116
124
  # `GET /files/<id>/content` (`headers` is case-insensitive, hash-like).
125
+ # Only a `2xx` body reaches `chunk_handler`. For any other status no chunk
126
+ # is yielded, so an error envelope can never become the downloaded file;
127
+ # the status is returned for the caller to answer with.
128
+ # @raise [AtlasRb::TombstonedError] when Atlas withholds the bytes because
129
+ # the Blob, its FileSet or its Work is tombstoned (Atlas 0.6.245 or
130
+ # later). Raised before any chunk is yielded.
117
131
  #
118
132
  # @example Stream the whole body to disk
119
133
  # File.open("/tmp/out.pdf", "wb") do |f|
@@ -126,11 +140,7 @@ module AtlasRb
126
140
  # res[:status] # => 206
127
141
  # res[:headers]["content-range"] # => "bytes 0-1048575/52428800"
128
142
  def self.content(id, range: nil, nuid: nil, on_behalf_of: nil, &chunk_handler)
129
- response = connection({}, nuid, on_behalf_of: on_behalf_of).get("#{ROUTE}#{id}/content") do |req|
130
- req.headers["Range"] = range if range
131
- req.options.on_data = proc { |chunk, _bytes_received, _env| chunk_handler.call(chunk) }
132
- end
133
- { status: response.status, headers: response.headers }
143
+ stream("#{ROUTE}#{id}/content", range: range, nuid: nuid, on_behalf_of: on_behalf_of, &chunk_handler)
134
144
  end
135
145
 
136
146
  # Upload a new Blob attached to a Work.
@@ -395,28 +405,77 @@ module AtlasRb
395
405
  # omitted.
396
406
  # @yieldparam chunk [String] the next chunk of binary data.
397
407
  # @return [Hash] `{ status:, headers: }` — the HTTP status and response
398
- # headers. The status is handed back rather than raised on, because
399
- # chunks reach the caller as they arrive: by the time the status could
400
- # be checked, an error body would already have been streamed. A caller
401
- # writing the chunks to an HTTP response (or a file) must consult it, or
402
- # an Atlas error page becomes the bytes of the downloaded file. Matches
403
- # {.content}'s contract.
408
+ # headers. As with {.content}, only a `2xx` body reaches `chunk_handler`,
409
+ # and any other status is returned with no chunk yielded.
410
+ # @raise [AtlasRb::TombstonedError] as {.content} does.
404
411
  #
405
412
  # @example Download a superseded version to disk
406
413
  # File.open("/tmp/old.pdf", "wb") do |f|
407
414
  # AtlasRb::Blob.version_content("b-321", "v1") { |chunk| f.write(chunk) }
408
415
  # end
409
416
  def self.version_content(id, version_id, nuid: nil, on_behalf_of: nil, &chunk_handler)
410
- headers = {}
411
- response = connection({}, nuid, on_behalf_of: on_behalf_of)
412
- .get("#{ROUTE}#{id}/versions/#{version_id}/content") do |req|
413
- req.options.on_data = proc do |chunk, _bytes_received, env|
414
- headers = env.response_headers if headers.empty? && env
415
- chunk_handler.call(chunk)
417
+ path = "#{ROUTE}#{id}/versions/#{version_id}/content"
418
+ stream(path, nuid: nuid, on_behalf_of: on_behalf_of, &chunk_handler)
419
+ end
420
+
421
+ # The most of an error body kept for the exception. Atlas's envelopes are a
422
+ # few hundred bytes; the cap only bounds a misbehaving proxy.
423
+ ERROR_BODY_LIMIT = 64 * 1024
424
+
425
+ # Faraday hands every chunk to `on_data` whatever the status, and a host
426
+ # streaming to a browser has sent its file headers before the first one
427
+ # arrives. So only a 2xx chunk reaches the caller. Any other body is kept
428
+ # back: a tombstone refusal raises from it, and every other status is
429
+ # returned with no chunk yielded.
430
+ #
431
+ # @api private
432
+ def self.stream(path, nuid:, on_behalf_of:, range: nil, &chunk_handler)
433
+ sink = StreamSink.new(chunk_handler)
434
+ response = connection({}, nuid, on_behalf_of: on_behalf_of).get(path) do |req|
435
+ req.headers["Range"] = range if range
436
+ # A Proc, not a Method: Faraday streams only when on_data is a Proc, and
437
+ # buffers the whole body otherwise.
438
+ req.options.on_data = sink.method(:receive).to_proc
439
+ end
440
+ raise_if_tombstoned(response, sink.error_body)
441
+ { status: response.status, headers: sink.headers || response.headers }
442
+ end
443
+ private_class_method :stream
444
+
445
+ # @api private
446
+ def self.raise_if_tombstoned(response, body)
447
+ return unless response.status == 410
448
+
449
+ envelope = AtlasRb::TombstonedError.envelope(body)
450
+ raise AtlasRb::TombstonedError.new(envelope, response: response, body: body) if envelope
451
+ end
452
+ private_class_method :raise_if_tombstoned
453
+
454
+ # Routes each streamed chunk by the status Faraday recorded before the first
455
+ # one: a 2xx chunk to the caller, any other into a bounded error body. An
456
+ # env with no status is passed on, because nothing says it is an error.
457
+ #
458
+ # @api private
459
+ class StreamSink
460
+ attr_reader :headers, :error_body
461
+
462
+ def initialize(chunk_handler)
463
+ @chunk_handler = chunk_handler
464
+ @headers = nil
465
+ @error_body = +""
466
+ end
467
+
468
+ def receive(chunk, _bytes_received, env)
469
+ @headers ||= env&.response_headers
470
+ status = env&.status
471
+ if status.nil? || (200..299).cover?(status)
472
+ @chunk_handler.call(chunk)
473
+ elsif @error_body.bytesize < ERROR_BODY_LIMIT
474
+ @error_body << chunk
416
475
  end
417
476
  end
418
- { status: response.status, headers: headers }
419
477
  end
478
+ private_constant :StreamSink
420
479
 
421
480
  # Roll a Blob back to a prior version.
422
481
  #
@@ -378,6 +378,61 @@ module AtlasRb
378
378
  # a caller that only wants "the write failed" can rescue the parent.
379
379
  class NotFoundError < ResourceError; end
380
380
 
381
+ # Raised when Atlas withholds a file, or a Work's file list, because of a
382
+ # tombstone: an HTTP `410` whose body carries `error: "tombstoned"`.
383
+ #
384
+ # A tombstone does not cascade, so the resource that carries it can be an
385
+ # ancestor of the one requested. A Blob is withheld when its FileSet is
386
+ # tombstoned (one file removed) or when its Work is (the whole Work
387
+ # retracted). {#tombstoned_resource_type} tells the two apart, so a host can
388
+ # show "file not found" for the first and its "Removed" page for the second.
389
+ #
390
+ # rescue AtlasRb::TombstonedError => e
391
+ # e.work_retracted? ? render("errors/gone", status: :gone) : head(:not_found)
392
+ #
393
+ # Admins and the devolved-admin tier are still served, so they never see
394
+ # this. A `410` from a single-resource read such as {Work.find} is not this:
395
+ # that one carries the resource's own body, and the read returns it.
396
+ #
397
+ # A subclass of {ResourceError}, so a caller that only wants "the read
398
+ # failed" can rescue the parent. Needs Atlas 0.6.245 or later.
399
+ class TombstonedError < ResourceError
400
+ # The discriminator Atlas renders. An exact-match wire contract.
401
+ CODE = "tombstoned"
402
+
403
+ # @return [String, nil] NOID of the resource whose tombstone applies.
404
+ attr_reader :tombstoned_resource_id
405
+
406
+ # @return [String, nil] its type: `"Work"`, `"FileSet"` or `"Blob"`.
407
+ attr_reader :tombstoned_resource_type
408
+
409
+ # @param envelope [Hash] Atlas's parsed `410` body.
410
+ # @param response [Faraday::Response, nil] the originating response.
411
+ # @param body [String, nil] the raw body, when `response` cannot carry it
412
+ # (a streamed read hands its body to `on_data`, not to the response).
413
+ def initialize(envelope, response: nil, body: nil)
414
+ super(envelope["message"] || "tombstoned", response: response)
415
+ @body = body if body
416
+ @tombstoned_resource_id = envelope["tombstoned_resource_id"]
417
+ @tombstoned_resource_type = envelope["tombstoned_resource_type"]
418
+ end
419
+
420
+ # @return [Boolean] true when the whole Work is tombstoned.
421
+ def work_retracted?
422
+ tombstoned_resource_type == "Work"
423
+ end
424
+
425
+ # @return [Hash, nil] the envelope when `body` is Atlas's tombstone
426
+ # refusal, parsed; `nil` for any other body.
427
+ # @api private
428
+ def self.envelope(body)
429
+ parsed = body.is_a?(Hash) ? body : JSON.parse(body.to_s)
430
+ parsed if parsed.is_a?(Hash) && parsed["error"] == CODE
431
+ rescue JSON::ParserError
432
+ nil
433
+ end
434
+ end
435
+
381
436
  # Raised when Atlas refuses a write because the repository-wide maintenance
382
437
  # window is open: an HTTP `503` whose body carries
383
438
  # `error: "read_only_mode"`.
@@ -412,8 +412,11 @@ module AtlasRb
412
412
  # reason: a dependency outage can answer any request on any shape. It
413
413
  # must run before the read guard, or an outage on a read becomes the
414
414
  # generic {AtlasRb::ResourceError} a host treats as a refusal.
415
+ # - {Middleware::RaiseOnTombstoned} — a withheld file or file list. It must
416
+ # run before the read guard too, which passes every `410` through.
415
417
  def error_middleware(builder, reads:)
416
418
  builder.use AtlasRb::Middleware::RaiseOnReadError if reads
419
+ builder.use AtlasRb::Middleware::RaiseOnTombstoned
417
420
  builder.use AtlasRb::Middleware::RaiseOnStaleResource
418
421
  builder.use AtlasRb::Middleware::RaiseOnResourceError
419
422
  builder.use AtlasRb::Middleware::RaiseOnReadOnlyMode
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtlasRb
4
+ module Middleware
5
+ # Translates Atlas's tombstone refusal into {AtlasRb::TombstonedError}.
6
+ #
7
+ # Atlas answers `410` with `error: "tombstoned"` when it withholds a file's
8
+ # bytes or a tombstoned Work's file list. Without this the refusal reaches
9
+ # {Middleware::RaiseOnReadError}, which lets every `410` through, and a list
10
+ # binding maps over the error envelope as if it were the list.
11
+ #
12
+ # Keyed on the **status + discriminator pair**. A `410` that carries the
13
+ # resource's own body — {AtlasRb::Work.find} on a tombstoned Work — is an
14
+ # answer, not a refusal, and passes through untouched.
15
+ #
16
+ # A streamed read never reaches here with a body: its chunks go to
17
+ # `on_data`. {AtlasRb::Blob.content} checks the status itself.
18
+ class RaiseOnTombstoned < Faraday::Middleware
19
+ # @param env [Faraday::Env] the completed response environment.
20
+ # @raise [AtlasRb::TombstonedError] on a `410` whose body carries
21
+ # `error: "tombstoned"`.
22
+ # @return [void]
23
+ def on_complete(env)
24
+ return unless env.status == 410
25
+
26
+ envelope = AtlasRb::TombstonedError.envelope(env.body)
27
+ raise AtlasRb::TombstonedError.new(envelope, response: env.response) if envelope
28
+ end
29
+ end
30
+ end
31
+ end
data/lib/atlas_rb/work.rb CHANGED
@@ -466,6 +466,9 @@ module AtlasRb
466
466
  #
467
467
  # `nil` when Atlas answers `404` — nothing is there to read, or, with a
468
468
  # misconfigured `ATLAS_URL`, the route is not Atlas's at all.
469
+ # @raise [AtlasRb::TombstonedError] when the Work is tombstoned and the
470
+ # caller is below the admin and devolved-admin tiers (Atlas 0.6.245 or
471
+ # later), so a retracted Work's files are never listed.
469
472
  # @raise [AtlasRb::ResourceError] on any non-2xx other than `404` / `410`
470
473
  # (an auth or validation envelope, a `5xx`, a proxy's `503`), carrying
471
474
  # Atlas's status and body so the failure is attributable at the boundary.
@@ -543,6 +546,9 @@ module AtlasRb
543
546
  #
544
547
  # `nil` when Atlas answers `404` — no such Work, or, with a misconfigured
545
548
  # `ATLAS_URL`, the route is not Atlas's at all.
549
+ # @raise [AtlasRb::TombstonedError] when the Work is tombstoned and the
550
+ # caller is below the admin and devolved-admin tiers (Atlas 0.6.245 or
551
+ # later), so a retracted Work's files are never listed.
546
552
  # @raise [AtlasRb::ResourceError] on any non-2xx other than `404` / `410`
547
553
  # (a `403` for a caller who cannot read the Work, a `5xx`, a proxy's
548
554
  # `503`), carrying Atlas's status and body so the failure is attributable
@@ -625,6 +631,9 @@ module AtlasRb
625
631
  #
626
632
  # `nil` when Atlas answers `404` — nothing is there to read, or, with a
627
633
  # misconfigured `ATLAS_URL`, the route is not Atlas's at all.
634
+ # @raise [AtlasRb::TombstonedError] when the Work is tombstoned and the
635
+ # caller is below the admin and devolved-admin tiers (Atlas 0.6.245 or
636
+ # later), so a retracted Work's files are never listed.
628
637
  # @raise [AtlasRb::ResourceError] on any non-2xx other than `404` / `410`
629
638
  # (an auth or validation envelope, a `5xx`, a proxy's `503`), carrying
630
639
  # Atlas's status and body so the failure is attributable at the boundary.
data/lib/atlas_rb.rb CHANGED
@@ -22,6 +22,7 @@ require_relative "atlas_rb/middleware/raise_on_stale_resource"
22
22
  require_relative "atlas_rb/middleware/raise_on_resource_error"
23
23
  require_relative "atlas_rb/middleware/raise_on_read_only_mode"
24
24
  require_relative "atlas_rb/middleware/raise_on_backend_unavailable"
25
+ require_relative "atlas_rb/middleware/raise_on_tombstoned"
25
26
  require_relative "atlas_rb/faraday_helper"
26
27
  require_relative "atlas_rb/mash"
27
28
  require_relative "atlas_rb/authentication"
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.29.0
4
+ version: 1.29.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cliff
@@ -177,6 +177,7 @@ files:
177
177
  - lib/atlas_rb/middleware/raise_on_read_only_mode.rb
178
178
  - lib/atlas_rb/middleware/raise_on_resource_error.rb
179
179
  - lib/atlas_rb/middleware/raise_on_stale_resource.rb
180
+ - lib/atlas_rb/middleware/raise_on_tombstoned.rb
180
181
  - lib/atlas_rb/person.rb
181
182
  - lib/atlas_rb/resource.rb
182
183
  - lib/atlas_rb/resource_writes.rb