atlas_rb 1.26.0 → 1.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/CHANGELOG.md +65 -0
- data/Gemfile.lock +1 -1
- data/README.md +64 -23
- data/lib/atlas_rb/collection.rb +5 -0
- data/lib/atlas_rb/community.rb +5 -0
- data/lib/atlas_rb/compilation.rb +3 -0
- data/lib/atlas_rb/errors.rb +41 -0
- data/lib/atlas_rb/faraday_helper.rb +5 -0
- data/lib/atlas_rb/health.rb +78 -0
- data/lib/atlas_rb/mash.rb +18 -0
- data/lib/atlas_rb/middleware/raise_on_backend_unavailable.rb +49 -0
- data/lib/atlas_rb/resource.rb +74 -7
- data/lib/atlas_rb/resource_writes.rb +1 -3
- data/lib/atlas_rb/work.rb +5 -0
- data/lib/atlas_rb.rb +5 -3
- metadata +4 -3
- data/lib/atlas_rb/resource_types.rb +0 -60
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2c1b0723d54145c8be9868a1849502cf80adbc1a30b2f7b1ee39aadbfa1a2f3b
|
|
4
|
+
data.tar.gz: 7ab2bb5a662b02dd48275e645f9c6b3c1226d6bc995231766bd5a4252f17b25a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 64f43d675ad66dac85657c0460702f53f95c99760e7a44ce6f0dec019e375f41b197c214ca3dc4d89a94a1e0b829fa9d377fd4dd9432fe68cdc53d9aa490e38e
|
|
7
|
+
data.tar.gz: dfc0ade69d92b8b100698091f6ec22d90eb428769bb61aebdc79a9e842840036bc45875bc330531be335d5a11459c823ff13a1ccd78e1b143b11009f083ca54b
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.27.1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,70 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.27.1
|
|
4
|
+
|
|
5
|
+
Requires an Atlas that serves `GET /health` and the `backend_unavailable`
|
|
6
|
+
envelope.
|
|
7
|
+
|
|
8
|
+
### Added — `AtlasRb::Health.read`
|
|
9
|
+
|
|
10
|
+
Reads Atlas's readiness report (`GET /health`). Returns the parsed body on a
|
|
11
|
+
`200` or a `503`, because a `503` there is an answer. Raises only on
|
|
12
|
+
transport failure, or `ResourceError` when the answer is not the report. No
|
|
13
|
+
credential, a 5-second deadline, no retry, and a fresh socket per call.
|
|
14
|
+
|
|
15
|
+
### Added — `AtlasRb::BackendUnavailableError`
|
|
16
|
+
|
|
17
|
+
Raised on every binding when Atlas answers `503` with
|
|
18
|
+
`error: "backend_unavailable"`. Carries `dependency` (`"database"`,
|
|
19
|
+
`"index"` or `"storage"`) and `retry_after`. A sibling of
|
|
20
|
+
`ReadOnlyModeError`, not a `ResourceError`. A bodyless `503` from a proxy
|
|
21
|
+
still passes through.
|
|
22
|
+
|
|
23
|
+
## 1.27.0
|
|
24
|
+
|
|
25
|
+
### Removed — `Resource.class_for` and `Resource::TYPE_MAP`
|
|
26
|
+
|
|
27
|
+
Callers no longer turn a type string into a class themselves. Replace
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
AtlasRb::Resource.class_for(found.klass)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
with
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
found.resource_class
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The lookup no longer accepts `"File_set"`, the spelling `find` emitted
|
|
40
|
+
before 1.17.0, or `"compilation"` / `"Compilation"`, which no Atlas payload
|
|
41
|
+
reports: the resolver covers Valkyrie-backed types only. Each now raises
|
|
42
|
+
`ArgumentError`. `lib/atlas_rb/resource_types.rb` is gone.
|
|
43
|
+
|
|
44
|
+
### Added — `Mash#resource_class`
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
AtlasRb::Resource.find(id).resource_class # => AtlasRb::FileSet
|
|
48
|
+
AtlasRb::Resource.search("whaling").results.first.resource_class
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Every Atlas payload that names a type carries it as `"klass"`, so the method
|
|
52
|
+
lives on the shared `Mash` and covers `find`, `find_many`, `ancestors` nodes
|
|
53
|
+
and the Solr digests. `klass` itself is unchanged.
|
|
54
|
+
|
|
55
|
+
### Added — `Resource.unresolvable!`
|
|
56
|
+
|
|
57
|
+
Every `Resource` subclass now registers itself when it is defined, under its
|
|
58
|
+
class name and its snake-cased wire key. A subclass that Atlas never names in
|
|
59
|
+
a payload opts out by calling `unresolvable!` in its body. `Compilation` is
|
|
60
|
+
the only one.
|
|
61
|
+
|
|
62
|
+
### Added — `mods_editable?`
|
|
63
|
+
|
|
64
|
+
`true` for `Work`, `Collection` and `Community`, `false` for every other
|
|
65
|
+
type. It matches Atlas's `ResourcesController::TYPED_IVARS`, which Atlas
|
|
66
|
+
does not expose on the wire.
|
|
67
|
+
|
|
3
68
|
## 1.26.0
|
|
4
69
|
|
|
5
70
|
These bindings need Atlas 0.6.221 or later.
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -615,34 +615,37 @@ Writes that address something only one type has stay typed — `create`, the
|
|
|
615
615
|
Work-only lifecycle and derivative writes, `Collection.set_featured`, and the
|
|
616
616
|
FileSet, Blob and Compilation writes.
|
|
617
617
|
|
|
618
|
-
### Resolving one id of unknown type (`Resource.find` and `
|
|
618
|
+
### Resolving one id of unknown type (`Resource.find` and `resource_class`)
|
|
619
619
|
|
|
620
620
|
When a NOID arrives as runtime data and you do not know its type, resolve
|
|
621
621
|
it generically and dispatch on the reported type:
|
|
622
622
|
|
|
623
623
|
```ruby
|
|
624
624
|
found = AtlasRb::Resource.find("b8gtjvk")
|
|
625
|
-
found
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
```
|
|
629
|
-
|
|
630
|
-
`
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
625
|
+
found.klass # => "FileSet"
|
|
626
|
+
found.resource_class # => AtlasRb::FileSet
|
|
627
|
+
found.resource_class.find(found.resource.id)
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
`resource_class` is the supported way to get from a result to its class.
|
|
631
|
+
It works on every Atlas payload that carries a `"klass"`: `find`,
|
|
632
|
+
`find_many`, `ancestors` nodes, and the `search`, `descendant_works` and
|
|
633
|
+
`Compilation.contents` digests. Keep `klass` when the type has to be
|
|
634
|
+
serialized — a job argument, a log line — and use `resource_class` when
|
|
635
|
+
you act on it. Do not `const_get` into the `AtlasRb` namespace.
|
|
636
|
+
|
|
637
|
+
Every `Resource` subclass registers itself when it is defined, under its
|
|
638
|
+
class name and the key Atlas wraps its JSON in (`FileSet` → `"file_set"`).
|
|
639
|
+
`Compilation` opts out with `unresolvable!`, because the resolver never
|
|
640
|
+
returns one.
|
|
641
|
+
A row naming a type no class registers raises `ArgumentError` rather than
|
|
642
|
+
returning `nil`, so a type this gem does not model fails where the cause
|
|
643
|
+
is.
|
|
644
|
+
|
|
645
|
+
To ask whether a type has an editable MODS document, use
|
|
646
|
+
`mods_editable?`: it is true for `Work`, `Collection` and `Community` only.
|
|
647
|
+
`Resource.mods` answers for every type, so a successful read does not mean
|
|
648
|
+
a write will be accepted.
|
|
646
649
|
|
|
647
650
|
**What the resolver covers.** Atlas answers `/resources/:id` for its
|
|
648
651
|
Valkyrie-backed types only: `Work`, `Collection`, `Community`, `FileSet`,
|
|
@@ -682,7 +685,7 @@ result = AtlasRb::Resource.search("whaling logbook", type: "Work", per_page: 50)
|
|
|
682
685
|
result.pagination.total # => 12
|
|
683
686
|
hit = result.results.first
|
|
684
687
|
hit.title # => "Whaling logbook, 1851"
|
|
685
|
-
|
|
688
|
+
hit.resource_class.find(hit.noid)
|
|
686
689
|
```
|
|
687
690
|
|
|
688
691
|
It searches Works, Collections, Communities and People, most relevant first.
|
|
@@ -816,6 +819,44 @@ A `503` from a reverse proxy while Atlas restarts carries no JSON body and is
|
|
|
816
819
|
a different condition with a different remedy, so it passes through as a plain
|
|
817
820
|
response rather than raising.
|
|
818
821
|
|
|
822
|
+
### Health and outages
|
|
823
|
+
|
|
824
|
+
`AtlasRb::Health.read` returns Atlas's readiness report. It sends no
|
|
825
|
+
credential, because Atlas serves `GET /health` unauthenticated:
|
|
826
|
+
|
|
827
|
+
```ruby
|
|
828
|
+
report = AtlasRb::Health.read
|
|
829
|
+
report["status"] # => "ok" / "degraded" / "unavailable"
|
|
830
|
+
report["database"] # => "ok" / "unavailable"
|
|
831
|
+
report["index"] # => "ok" / "unavailable"
|
|
832
|
+
report["storage"] # => "ok" / "unavailable"
|
|
833
|
+
report["cache"] # => "ok" / "unavailable" / "disabled"
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
Atlas answers `503` when the database, the index or the storage is down. That
|
|
837
|
+
is an answer, so `read` returns it like a `200`. It raises
|
|
838
|
+
`Faraday::ConnectionFailed` or `Faraday::TimeoutError` only when nothing
|
|
839
|
+
answers, and `AtlasRb::ResourceError` when something answers that is not the
|
|
840
|
+
report. It has a 5-second deadline (`read(timeout: 3)` to change it) and no
|
|
841
|
+
retry, so a probe answers inside its poller's own deadline.
|
|
842
|
+
|
|
843
|
+
When an ordinary call meets a dependency outage, Atlas answers `503` with
|
|
844
|
+
`error: "backend_unavailable"`, and every binding raises:
|
|
845
|
+
|
|
846
|
+
```ruby
|
|
847
|
+
begin
|
|
848
|
+
AtlasRb::Work.find("w-789")
|
|
849
|
+
rescue AtlasRb::BackendUnavailableError => e
|
|
850
|
+
e.dependency # => "database" / "index" / "storage"
|
|
851
|
+
e.retry_after # => 30
|
|
852
|
+
end
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
It is not a `ResourceError`, which hosts treat as a refusal of the request.
|
|
856
|
+
|
|
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`.
|
|
859
|
+
|
|
819
860
|
## End-to-end example
|
|
820
861
|
|
|
821
862
|
JSON responses come back as `AtlasRb::Mash` (a `Hashie::Mash` subclass), so
|
data/lib/atlas_rb/collection.rb
CHANGED
|
@@ -16,6 +16,11 @@ module AtlasRb
|
|
|
16
16
|
# @api private
|
|
17
17
|
ROUTE = "/collections/"
|
|
18
18
|
|
|
19
|
+
# Atlas accepts a MODS write for this type. See {Resource.mods_editable?}.
|
|
20
|
+
def self.mods_editable?
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
|
|
19
24
|
# Fetch a single Collection by ID.
|
|
20
25
|
#
|
|
21
26
|
# @param id [String] the Collection ID.
|
data/lib/atlas_rb/community.rb
CHANGED
|
@@ -14,6 +14,11 @@ module AtlasRb
|
|
|
14
14
|
# @api private
|
|
15
15
|
ROUTE = "/communities/"
|
|
16
16
|
|
|
17
|
+
# Atlas accepts a MODS write for this type. See {Resource.mods_editable?}.
|
|
18
|
+
def self.mods_editable?
|
|
19
|
+
true
|
|
20
|
+
end
|
|
21
|
+
|
|
17
22
|
# Fetch a single Community by ID.
|
|
18
23
|
#
|
|
19
24
|
# @param id [String] the Community ID.
|
data/lib/atlas_rb/compilation.rb
CHANGED
|
@@ -24,6 +24,9 @@ module AtlasRb
|
|
|
24
24
|
# Atlas REST endpoint prefix for this resource.
|
|
25
25
|
# @api private
|
|
26
26
|
ROUTE = "/compilations/"
|
|
27
|
+
# Atlas's resolver covers Valkyrie-backed types only, and a Compilation is
|
|
28
|
+
# an ActiveRecord row there, so no Atlas payload names one as its klass.
|
|
29
|
+
unresolvable!
|
|
27
30
|
|
|
28
31
|
# Fetch a single Compilation by ID.
|
|
29
32
|
#
|
data/lib/atlas_rb/errors.rb
CHANGED
|
@@ -420,6 +420,47 @@ module AtlasRb
|
|
|
420
420
|
end
|
|
421
421
|
end
|
|
422
422
|
|
|
423
|
+
# Raised when Atlas cannot reach one of its own dependencies: an HTTP `503`
|
|
424
|
+
# whose body carries `error: "backend_unavailable"` and a `dependency` key
|
|
425
|
+
# naming `"database"`, `"index"` or `"storage"`.
|
|
426
|
+
#
|
|
427
|
+
# A sibling of {ReadOnlyModeError}, and deliberately NOT a {ResourceError}.
|
|
428
|
+
# Hosts commonly treat a `ResourceError` as a refusal of the request (Cerberus's
|
|
429
|
+
# `Authorizable` renders it as one), and an outage is no statement about the
|
|
430
|
+
# caller or the resource. Without this the outage reaches the host as the
|
|
431
|
+
# generic `ResourceError` a `5xx` read raises, indistinguishable from a bug.
|
|
432
|
+
#
|
|
433
|
+
# rescue AtlasRb::BackendUnavailableError => e
|
|
434
|
+
# render "outage", status: :service_unavailable
|
|
435
|
+
# response.headers["Retry-After"] = e.retry_after.to_s if e.retry_after
|
|
436
|
+
#
|
|
437
|
+
# A bodyless `503` from a proxy is not this; it keeps reaching the caller as
|
|
438
|
+
# it did before. Poll {AtlasRb::Health.read} to see every dependency at once.
|
|
439
|
+
class BackendUnavailableError < Error
|
|
440
|
+
# @return [String, nil] the envelope's `error` discriminator
|
|
441
|
+
# (`"backend_unavailable"`).
|
|
442
|
+
attr_reader :code
|
|
443
|
+
|
|
444
|
+
# @return [String, nil] which dependency failed: `"database"`, `"index"` or
|
|
445
|
+
# `"storage"`.
|
|
446
|
+
attr_reader :dependency
|
|
447
|
+
|
|
448
|
+
# @return [Integer, nil] seconds to wait before retrying, from Atlas's
|
|
449
|
+
# `Retry-After` response header.
|
|
450
|
+
attr_reader :retry_after
|
|
451
|
+
|
|
452
|
+
# @param message [String, nil] human-readable description.
|
|
453
|
+
# @param code [String, nil] the envelope's `error` discriminator.
|
|
454
|
+
# @param dependency [String, nil] the dependency that failed.
|
|
455
|
+
# @param retry_after [Integer, nil] seconds from the `Retry-After` header.
|
|
456
|
+
def initialize(message=nil, code: nil, dependency: nil, retry_after: nil)
|
|
457
|
+
@code = code
|
|
458
|
+
@dependency = dependency
|
|
459
|
+
@retry_after = retry_after
|
|
460
|
+
super(message || "Atlas cannot reach its #{dependency || "backend"}")
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
423
464
|
# Raised when the transport has no way to authenticate a relay request:
|
|
424
465
|
# neither `ATLAS_JWT` (BYO-JWT mode) nor a signing key
|
|
425
466
|
# ({AtlasRb.config#assertion_signing_key}, relay-signing mode) is configured.
|
|
@@ -408,11 +408,16 @@ module AtlasRb
|
|
|
408
408
|
# above: a maintenance window refuses writes on every path and a `503`
|
|
409
409
|
# reaches neither of them. On all three shapes, because a write that
|
|
410
410
|
# slips past it silently unwraps nil and reports success.
|
|
411
|
+
# - {Middleware::RaiseOnBackendUnavailable} — path-independent for the same
|
|
412
|
+
# reason: a dependency outage can answer any request on any shape. It
|
|
413
|
+
# must run before the read guard, or an outage on a read becomes the
|
|
414
|
+
# generic {AtlasRb::ResourceError} a host treats as a refusal.
|
|
411
415
|
def error_middleware(builder, reads:)
|
|
412
416
|
builder.use AtlasRb::Middleware::RaiseOnReadError if reads
|
|
413
417
|
builder.use AtlasRb::Middleware::RaiseOnStaleResource
|
|
414
418
|
builder.use AtlasRb::Middleware::RaiseOnResourceError
|
|
415
419
|
builder.use AtlasRb::Middleware::RaiseOnReadOnlyMode
|
|
420
|
+
builder.use AtlasRb::Middleware::RaiseOnBackendUnavailable
|
|
416
421
|
end
|
|
417
422
|
|
|
418
423
|
# Retry an idempotent read that failed in transport, in exactly one layer.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtlasRb
|
|
4
|
+
# Atlas's readiness report (`GET /health`): whether it can reach its database,
|
|
5
|
+
# its Solr index, its storage and its cache.
|
|
6
|
+
#
|
|
7
|
+
# Atlas answers `200` when every required dependency answers and `503` when
|
|
8
|
+
# one does not, with the same body either way:
|
|
9
|
+
#
|
|
10
|
+
# { "status" => "degraded", "database" => "ok", "index" => "unavailable",
|
|
11
|
+
# "storage" => "ok", "cache" => "ok" }
|
|
12
|
+
#
|
|
13
|
+
# The endpoint is unauthenticated, so this sends no credential, and works
|
|
14
|
+
# without a signing key or `ATLAS_JWT` configured.
|
|
15
|
+
#
|
|
16
|
+
# Not to be confused with `GET /up`, the liveness route the orchestrator
|
|
17
|
+
# polls; it has no binding.
|
|
18
|
+
class Health
|
|
19
|
+
extend AtlasRb::FaradayHelper
|
|
20
|
+
|
|
21
|
+
# Seconds to wait for a socket to open.
|
|
22
|
+
OPEN_TIMEOUT = 2
|
|
23
|
+
|
|
24
|
+
# Seconds to wait for the report. Atlas bounds each of its own probes, so a
|
|
25
|
+
# slower answer means Atlas itself is struggling. Short enough to answer
|
|
26
|
+
# inside a monitor's own deadline (Nagios's `check_http` defaults to 10s).
|
|
27
|
+
READ_TIMEOUT = 5
|
|
28
|
+
|
|
29
|
+
# Read the report.
|
|
30
|
+
#
|
|
31
|
+
# A `503` here is an answer, not an error, so it is returned like a `200`;
|
|
32
|
+
# read `status` off the result. There is no retry: a probe must answer
|
|
33
|
+
# faster than its poller times out, and a retry would hide the very
|
|
34
|
+
# flakiness it exists to show.
|
|
35
|
+
#
|
|
36
|
+
# @param timeout [Numeric] seconds to wait for the report.
|
|
37
|
+
# @return [AtlasRb::Mash] `status` (`"ok"` / `"degraded"` /
|
|
38
|
+
# `"unavailable"`), and `database`, `index`, `storage` (`"ok"` /
|
|
39
|
+
# `"unavailable"`) and `cache` (`"ok"` / `"unavailable"` / `"disabled"`).
|
|
40
|
+
# @raise [Faraday::ConnectionFailed, Faraday::TimeoutError] when Atlas
|
|
41
|
+
# does not answer at all.
|
|
42
|
+
# @raise [AtlasRb::ResourceError] when something answers that is not
|
|
43
|
+
# Atlas's report: a `404` from an Atlas that predates the endpoint, or a
|
|
44
|
+
# proxy's `502` with an HTML body.
|
|
45
|
+
def self.read(timeout: READ_TIMEOUT)
|
|
46
|
+
resp = health_connection(timeout).get("/health")
|
|
47
|
+
report = parse_report(resp)
|
|
48
|
+
return AtlasRb::Mash.new(report) if report
|
|
49
|
+
|
|
50
|
+
raise AtlasRb::ResourceError.new("#{request_target(resp)} → #{resp.status}: #{resp.body}", response: resp)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class << self
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# A fresh socket per call rather than the shared pool. With no retry, a
|
|
57
|
+
# pooled socket the server had closed would report a healthy Atlas as
|
|
58
|
+
# down; a probe runs far too rarely for the handshake to matter.
|
|
59
|
+
def health_connection(timeout)
|
|
60
|
+
Faraday.new(url: ENV.fetch("ATLAS_URL", nil)) do |f|
|
|
61
|
+
f.options.open_timeout = OPEN_TIMEOUT
|
|
62
|
+
f.options.timeout = timeout
|
|
63
|
+
instrument(f)
|
|
64
|
+
f.adapter :net_http
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parse_report(resp)
|
|
69
|
+
return nil unless [200, 503].include?(resp.status)
|
|
70
|
+
|
|
71
|
+
body = JSON.parse(resp.body.to_s)
|
|
72
|
+
body.is_a?(Hash) && body.key?("status") ? body : nil
|
|
73
|
+
rescue JSON::ParserError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/atlas_rb/mash.rb
CHANGED
|
@@ -12,5 +12,23 @@ module AtlasRb
|
|
|
12
12
|
# `type`, `id`).
|
|
13
13
|
class Mash < Hashie::Mash
|
|
14
14
|
disable_warnings
|
|
15
|
+
|
|
16
|
+
# The AtlasRb class named by this row's `"klass"`.
|
|
17
|
+
#
|
|
18
|
+
# Every Atlas payload that names a type carries it as `"klass"`, in the
|
|
19
|
+
# class-name spelling: {Resource.find}, {Resource.find_many}, `ancestors`
|
|
20
|
+
# nodes, and the Solr digests from {Resource.search},
|
|
21
|
+
# {Resource.descendant_works} and {Compilation.contents}. Nested hashes
|
|
22
|
+
# are Mashes too, so an `ancestors` node answers this as well.
|
|
23
|
+
#
|
|
24
|
+
# @return [Class] the AtlasRb resource class.
|
|
25
|
+
# @raise [ArgumentError] when the row has no `"klass"`, or names a type
|
|
26
|
+
# this gem does not register.
|
|
27
|
+
# @example Load a search hit as its full resource
|
|
28
|
+
# hit = AtlasRb::Resource.search("whaling").results.first
|
|
29
|
+
# hit.resource_class.find(hit.noid)
|
|
30
|
+
def resource_class
|
|
31
|
+
Resource.resolve_type(self["klass"])
|
|
32
|
+
end
|
|
15
33
|
end
|
|
16
34
|
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtlasRb
|
|
4
|
+
module Middleware
|
|
5
|
+
# Translates Atlas's dependency-outage answer into a typed Ruby exception.
|
|
6
|
+
#
|
|
7
|
+
# When Atlas cannot reach its database, its Solr index or its storage, it
|
|
8
|
+
# answers `503 Service Unavailable` with the discriminator
|
|
9
|
+
# `error: "backend_unavailable"`, a `dependency` key and a `Retry-After`
|
|
10
|
+
# header. This middleware keys on the **status + discriminator pair** and
|
|
11
|
+
# raises {AtlasRb::BackendUnavailableError}.
|
|
12
|
+
#
|
|
13
|
+
# Path-independent, like {RaiseOnReadOnlyMode}: an outage can answer any
|
|
14
|
+
# request. A 503 without Atlas's envelope — a proxy while Atlas restarts —
|
|
15
|
+
# passes through untouched, because that is a different condition.
|
|
16
|
+
class RaiseOnBackendUnavailable < Faraday::Middleware
|
|
17
|
+
# The discriminator Atlas renders. An exact-match wire contract.
|
|
18
|
+
CODE = "backend_unavailable"
|
|
19
|
+
|
|
20
|
+
# @param env [Faraday::Env] the completed response environment.
|
|
21
|
+
# @raise [AtlasRb::BackendUnavailableError] on a 503 whose body carries
|
|
22
|
+
# `error: "backend_unavailable"`.
|
|
23
|
+
# @return [void]
|
|
24
|
+
def on_complete(env)
|
|
25
|
+
return unless env.status == 503
|
|
26
|
+
|
|
27
|
+
body = parse_json(env.body)
|
|
28
|
+
return unless body.is_a?(Hash) && body["error"] == CODE
|
|
29
|
+
|
|
30
|
+
raise AtlasRb::BackendUnavailableError.new(
|
|
31
|
+
body["message"],
|
|
32
|
+
code: body["error"],
|
|
33
|
+
dependency: body["dependency"],
|
|
34
|
+
retry_after: env.response_headers&.[]("retry-after")&.to_i
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def parse_json(body)
|
|
41
|
+
return body if body.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
JSON.parse(body.to_s)
|
|
44
|
+
rescue JSON::ParserError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/atlas_rb/resource.rb
CHANGED
|
@@ -18,6 +18,73 @@ module AtlasRb
|
|
|
18
18
|
class Resource
|
|
19
19
|
extend AtlasRb::FaradayHelper
|
|
20
20
|
|
|
21
|
+
# Every type the Atlas resolver can answer with, filled by {inherited} as
|
|
22
|
+
# each subclass loads and frozen in `atlas_rb.rb` once they all have. Keyed
|
|
23
|
+
# by both spellings Atlas produces: the wire key (`"file_set"`) and the
|
|
24
|
+
# class name Solr indexes as `internal_resource` (`"FileSet"`).
|
|
25
|
+
# @api private
|
|
26
|
+
TYPES = {}
|
|
27
|
+
|
|
28
|
+
# Registers every subclass as a type the resolver can answer with, unless
|
|
29
|
+
# it opts out with {unresolvable!}.
|
|
30
|
+
#
|
|
31
|
+
# The wire key is the class name in snake case, which is the root key
|
|
32
|
+
# Atlas wraps the type's JSON in (`json.file_set do`). That holds for every
|
|
33
|
+
# type today; a type whose wire name ever differs would need a way to say
|
|
34
|
+
# so.
|
|
35
|
+
#
|
|
36
|
+
# An anonymous class, or one defined after the gem has loaded, is not
|
|
37
|
+
# registered: the set of Atlas types is the gem's, and `TYPES` is frozen
|
|
38
|
+
# by then.
|
|
39
|
+
#
|
|
40
|
+
# @api private
|
|
41
|
+
# @return [void]
|
|
42
|
+
def self.inherited(subclass)
|
|
43
|
+
super
|
|
44
|
+
return if subclass.name.nil? || TYPES.frozen?
|
|
45
|
+
|
|
46
|
+
class_name = subclass.name.split("::").last
|
|
47
|
+
TYPES[class_name.gsub(/(?<!\A)([A-Z])/, '_\1').downcase] = subclass
|
|
48
|
+
TYPES[class_name] = subclass
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Removes this class from the types the resolver can answer with. For a
|
|
52
|
+
# subclass that Atlas never names in a payload.
|
|
53
|
+
#
|
|
54
|
+
# @return [void]
|
|
55
|
+
def self.unresolvable!
|
|
56
|
+
TYPES.delete_if { |_, klass| klass == self }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The class registered for a type string, in either spelling.
|
|
60
|
+
#
|
|
61
|
+
# An unknown type raises rather than answering `nil`, so a caller holding a
|
|
62
|
+
# type this gem does not define hears about it here instead of at a
|
|
63
|
+
# `NoMethodError` a few frames later.
|
|
64
|
+
#
|
|
65
|
+
# @api private
|
|
66
|
+
# @param name [String, Symbol] a wire key or class name.
|
|
67
|
+
# @return [Class]
|
|
68
|
+
# @raise [ArgumentError] when no class is registered for that type.
|
|
69
|
+
def self.resolve_type(name)
|
|
70
|
+
TYPES.fetch(name.to_s) do
|
|
71
|
+
raise ArgumentError, "unknown Atlas resource type: #{name.inspect}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Whether Atlas accepts a MODS write for this type.
|
|
76
|
+
#
|
|
77
|
+
# {Resource.mods} is defined here on the base class, so every type answers
|
|
78
|
+
# the read — a FileSet's succeeds and returns nothing a curator can edit.
|
|
79
|
+
# Only {Work}, {Collection} and {Community} take a write, the same set as
|
|
80
|
+
# Atlas's `ResourcesController::TYPED_IVARS`. Atlas does not expose that
|
|
81
|
+
# set on the wire, so it is stated here.
|
|
82
|
+
#
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def self.mods_editable?
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
|
|
21
88
|
# Resolve any Atlas resource by ID without knowing its type up front.
|
|
22
89
|
#
|
|
23
90
|
# The Atlas server returns a single-key JSON object whose key names the
|
|
@@ -45,12 +112,12 @@ module AtlasRb
|
|
|
45
112
|
# when the id resolves to nothing (`404`):
|
|
46
113
|
# - `"klass"` — the resource type as its class name, e.g. `"Work"`,
|
|
47
114
|
# `"FileSet"`. That is the spelling Solr carries as
|
|
48
|
-
# `internal_resource`, and {
|
|
115
|
+
# `internal_resource`, and {Mash#resource_class} turns it into the class.
|
|
49
116
|
# - `"resource"` — the resource payload as a Hash.
|
|
50
117
|
# @raise [AtlasRb::ResourceError] on any non-2xx other than `404` / `410` (e.g. an
|
|
51
118
|
# auth/validation error envelope), carrying Atlas's status + body.
|
|
52
|
-
# @raise [ArgumentError] when Atlas names a type this gem
|
|
53
|
-
# for. See {Resource.
|
|
119
|
+
# @raise [ArgumentError] when Atlas names a type this gem registers no class
|
|
120
|
+
# for. See {Resource.inherited}.
|
|
54
121
|
#
|
|
55
122
|
# @example Polymorphic lookup
|
|
56
123
|
# AtlasRb::Resource.find("abc123")
|
|
@@ -62,7 +129,7 @@ module AtlasRb
|
|
|
62
129
|
# The class's own name, never `capitalize` over the wire key: `capitalize`
|
|
63
130
|
# answers `"File_set"` for a `file_set`, which is not a constant in this
|
|
64
131
|
# namespace, and callers resolve this string to a class.
|
|
65
|
-
AtlasRb::Mash.new("klass" =>
|
|
132
|
+
AtlasRb::Mash.new("klass" => resolve_type(result.first[0]).name.split("::").last,
|
|
66
133
|
"resource" => result.first[1])
|
|
67
134
|
end
|
|
68
135
|
|
|
@@ -167,8 +234,8 @@ module AtlasRb
|
|
|
167
234
|
# Each row is a digest read off Solr, not a resource: `{ "id", "noid",
|
|
168
235
|
# "klass", "title", "creators", "year", "thumbnail", "in_progress",
|
|
169
236
|
# "embargoed", "incomplete" }`. `klass` is one of the four type names
|
|
170
|
-
# above,
|
|
171
|
-
#
|
|
237
|
+
# above, so a caller loads the full resource with
|
|
238
|
+
# `hit.resource_class.find(hit.noid)`.
|
|
172
239
|
#
|
|
173
240
|
# @param query [String, nil] the search text. Omit or pass blank to browse
|
|
174
241
|
# everything the user may read, newest first.
|
|
@@ -189,7 +256,7 @@ module AtlasRb
|
|
|
189
256
|
# @example Find a Work by a word in its title, then load it
|
|
190
257
|
# result = AtlasRb::Resource.search("whaling logbook", type: "Work")
|
|
191
258
|
# hit = result.results.first
|
|
192
|
-
#
|
|
259
|
+
# hit.resource_class.find(hit.noid)
|
|
193
260
|
# @example Page through every match
|
|
194
261
|
# (1..AtlasRb::Resource.search("whaling").pagination.pages).flat_map do |page|
|
|
195
262
|
# AtlasRb::Resource.search("whaling", page: page).results
|
|
@@ -5,9 +5,7 @@ module AtlasRb
|
|
|
5
5
|
# as a verb on the `/resources/{id}` sub-resource that already serves its
|
|
6
6
|
# `GET`, so a caller holding only a NOID never resolves the type first.
|
|
7
7
|
#
|
|
8
|
-
# Loaded after the subclasses because the typed writes delegate here
|
|
9
|
-
# because {Resource::TYPE_MAP} in `resource_types.rb` has the same ordering
|
|
10
|
-
# requirement.
|
|
8
|
+
# Loaded after the subclasses because the typed writes delegate here.
|
|
11
9
|
#
|
|
12
10
|
# **The names are deliberately not `update` and `metadata`.** Neither typed
|
|
13
11
|
# name says which document it writes, and `Resource.mods` / `.permissions`
|
data/lib/atlas_rb/work.rb
CHANGED
|
@@ -13,6 +13,11 @@ module AtlasRb
|
|
|
13
13
|
# @api private
|
|
14
14
|
ROUTE = "/works/"
|
|
15
15
|
|
|
16
|
+
# Atlas accepts a MODS write for this type. See {Resource.mods_editable?}.
|
|
17
|
+
def self.mods_editable?
|
|
18
|
+
true
|
|
19
|
+
end
|
|
20
|
+
|
|
16
21
|
# Fetch a single Work by ID.
|
|
17
22
|
#
|
|
18
23
|
# @param id [String] the Work ID.
|
data/lib/atlas_rb.rb
CHANGED
|
@@ -21,6 +21,7 @@ require_relative "atlas_rb/middleware/raise_on_read_error"
|
|
|
21
21
|
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
|
+
require_relative "atlas_rb/middleware/raise_on_backend_unavailable"
|
|
24
25
|
require_relative "atlas_rb/faraday_helper"
|
|
25
26
|
require_relative "atlas_rb/mash"
|
|
26
27
|
require_relative "atlas_rb/authentication"
|
|
@@ -33,9 +34,9 @@ require_relative "atlas_rb/blob"
|
|
|
33
34
|
require_relative "atlas_rb/delegate"
|
|
34
35
|
require_relative "atlas_rb/compilation"
|
|
35
36
|
require_relative "atlas_rb/person"
|
|
36
|
-
#
|
|
37
|
-
|
|
38
|
-
#
|
|
37
|
+
# Every resolvable type has registered itself by now.
|
|
38
|
+
AtlasRb::Resource::TYPES.freeze
|
|
39
|
+
# Must follow every Resource subclass above: the typed writes delegate here.
|
|
39
40
|
require_relative "atlas_rb/resource_writes"
|
|
40
41
|
require_relative "atlas_rb/user"
|
|
41
42
|
require_relative "atlas_rb/admin"
|
|
@@ -46,6 +47,7 @@ require_relative "atlas_rb/system/token"
|
|
|
46
47
|
require_relative "atlas_rb/system/work"
|
|
47
48
|
require_relative "atlas_rb/audit_event"
|
|
48
49
|
require_relative "atlas_rb/maintenance"
|
|
50
|
+
require_relative "atlas_rb/health"
|
|
49
51
|
|
|
50
52
|
# Ruby client for the Atlas API — Northeastern University's institutional
|
|
51
53
|
# digital repository.
|
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.
|
|
4
|
+
version: 1.27.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Cliff
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|
|
@@ -170,15 +170,16 @@ files:
|
|
|
170
170
|
- lib/atlas_rb/errors.rb
|
|
171
171
|
- lib/atlas_rb/faraday_helper.rb
|
|
172
172
|
- lib/atlas_rb/file_set.rb
|
|
173
|
+
- lib/atlas_rb/health.rb
|
|
173
174
|
- lib/atlas_rb/maintenance.rb
|
|
174
175
|
- lib/atlas_rb/mash.rb
|
|
176
|
+
- lib/atlas_rb/middleware/raise_on_backend_unavailable.rb
|
|
175
177
|
- lib/atlas_rb/middleware/raise_on_read_error.rb
|
|
176
178
|
- lib/atlas_rb/middleware/raise_on_read_only_mode.rb
|
|
177
179
|
- lib/atlas_rb/middleware/raise_on_resource_error.rb
|
|
178
180
|
- lib/atlas_rb/middleware/raise_on_stale_resource.rb
|
|
179
181
|
- lib/atlas_rb/person.rb
|
|
180
182
|
- lib/atlas_rb/resource.rb
|
|
181
|
-
- lib/atlas_rb/resource_types.rb
|
|
182
183
|
- lib/atlas_rb/resource_writes.rb
|
|
183
184
|
- lib/atlas_rb/system.rb
|
|
184
185
|
- lib/atlas_rb/system/token.rb
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module AtlasRb
|
|
4
|
-
# Reopens {Resource} with the resource-type vocabulary. This lives in its own
|
|
5
|
-
# file, required after the eight subclasses, because the map holds the classes
|
|
6
|
-
# themselves and every one of them subclasses {Resource} — so none of them is
|
|
7
|
-
# defined yet while `resource.rb` is loading.
|
|
8
|
-
class Resource
|
|
9
|
-
# Every type the Atlas resolver can answer with, keyed by each spelling the
|
|
10
|
-
# DRS stack produces for it: Atlas's wire key (`"file_set"`), the Ruby class
|
|
11
|
-
# name Solr indexes as `internal_resource` (`"FileSet"`), and the
|
|
12
|
-
# capitalize-of-the-wire-key form (`"File_set"`) that a caller can still be
|
|
13
|
-
# holding from a value this gem emitted before {Resource.class_for} existed.
|
|
14
|
-
# A caller cannot tell which of the three it holds, so all three resolve.
|
|
15
|
-
#
|
|
16
|
-
# Stated rather than derived: {Blob}'s `ROUTE` is `/files/`, so nothing in
|
|
17
|
-
# the routes turns a type into its class either.
|
|
18
|
-
TYPE_MAP = {
|
|
19
|
-
"work" => Work, "Work" => Work,
|
|
20
|
-
"collection" => Collection, "Collection" => Collection,
|
|
21
|
-
"community" => Community, "Community" => Community,
|
|
22
|
-
"compilation" => Compilation, "Compilation" => Compilation,
|
|
23
|
-
"file_set" => FileSet, "FileSet" => FileSet, "File_set" => FileSet,
|
|
24
|
-
"blob" => Blob, "Blob" => Blob,
|
|
25
|
-
"delegate" => Delegate, "Delegate" => Delegate,
|
|
26
|
-
"person" => Person, "Person" => Person
|
|
27
|
-
}.freeze
|
|
28
|
-
|
|
29
|
-
# Resolve a resource-type string to the class that models it.
|
|
30
|
-
#
|
|
31
|
-
# Use this on any type that arrives as runtime data — {Resource.find}'s
|
|
32
|
-
# `"klass"`, a Solr `internal_resource` value, or Atlas's wire key — rather
|
|
33
|
-
# than reaching into this namespace with `const_get`. The set is closed and
|
|
34
|
-
# stated in {TYPE_MAP}; it is not a naming rule.
|
|
35
|
-
#
|
|
36
|
-
# An unrecognized type raises instead of resolving to `nil` or to whatever
|
|
37
|
-
# constant happens to bear that name. A caller holding a type this gem does
|
|
38
|
-
# not define has to hear about it here, where the cause is, rather than at
|
|
39
|
-
# the `NoMethodError` a few frames later.
|
|
40
|
-
#
|
|
41
|
-
# @param name [String, Symbol] a resource type in any of the three
|
|
42
|
-
# spellings {TYPE_MAP} accepts.
|
|
43
|
-
# @return [Class] the AtlasRb class for that type.
|
|
44
|
-
# @raise [ArgumentError] when the gem defines no class for that type.
|
|
45
|
-
#
|
|
46
|
-
# @example Dispatching on a type that arrives as data
|
|
47
|
-
# found = AtlasRb::Resource.find("b8gtjvk")
|
|
48
|
-
# AtlasRb::Resource.class_for(found["klass"]).find(found["resource"]["id"])
|
|
49
|
-
#
|
|
50
|
-
# @example Every spelling of one type
|
|
51
|
-
# AtlasRb::Resource.class_for("file_set") # => AtlasRb::FileSet
|
|
52
|
-
# AtlasRb::Resource.class_for("FileSet") # => AtlasRb::FileSet
|
|
53
|
-
# AtlasRb::Resource.class_for("File_set") # => AtlasRb::FileSet
|
|
54
|
-
def self.class_for(name)
|
|
55
|
-
TYPE_MAP.fetch(name.to_s) do
|
|
56
|
-
raise ArgumentError, "unknown Atlas resource type: #{name.inspect}"
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
end
|