atlas_rb 1.25.0 → 1.27.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: 2274a10469db90f642ca79a877ab37f9db69c294c55737a28824885249ef6220
4
- data.tar.gz: c16a9865aa30a521823c17587e80179e681fab4a9c3747957ab0b943e8591548
3
+ metadata.gz: 40ea8cbef3fa493fefcf29c0dd15a4d7edf72efb58134f8d21050421517ef05f
4
+ data.tar.gz: 36a4eb18338f27da7ee744233e7b54fd6eb20faa76201e907f94703b8c6583e2
5
5
  SHA512:
6
- metadata.gz: fbf77b86e3b695401be85f2f913d4c71820ae2e1c2078884d6b13038d46cca749aaafaf7ef588e9f742db01e8b53b5375734893572430a9ccc7adbfed5444e50
7
- data.tar.gz: baf502af151908b4da14e4e51597d60f7c88b90d7659deac2731bd44dc317376dce11bbb8e2c2e9f3d7fbfc33adc0e8b6572124769651e7917bfb51eaaac76b5
6
+ metadata.gz: d33812411e71cfa8bf0031fae4f81b735d598b75cb31f099e0c67a31d8dc0e4cc9c193524443f8dce983f87f96add5c9cc82c6de8c3d1d4211e69611254d4cc6
7
+ data.tar.gz: f287d655a86594da49f54caa8cb505277577b3b81284e313cf956ee6424fd09f3bdf0085ae51fae14420468c386626eba3c300be3f41e5762d57c65de5938213
data/.version CHANGED
@@ -1 +1 @@
1
- 1.25.0
1
+ 1.27.0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,78 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.27.0
4
+
5
+ ### Removed — `Resource.class_for` and `Resource::TYPE_MAP`
6
+
7
+ Callers no longer turn a type string into a class themselves. Replace
8
+
9
+ ```ruby
10
+ AtlasRb::Resource.class_for(found.klass)
11
+ ```
12
+
13
+ with
14
+
15
+ ```ruby
16
+ found.resource_class
17
+ ```
18
+
19
+ The lookup no longer accepts `"File_set"`, the spelling `find` emitted
20
+ before 1.17.0, or `"compilation"` / `"Compilation"`, which no Atlas payload
21
+ reports: the resolver covers Valkyrie-backed types only. Each now raises
22
+ `ArgumentError`. `lib/atlas_rb/resource_types.rb` is gone.
23
+
24
+ ### Added — `Mash#resource_class`
25
+
26
+ ```ruby
27
+ AtlasRb::Resource.find(id).resource_class # => AtlasRb::FileSet
28
+ AtlasRb::Resource.search("whaling").results.first.resource_class
29
+ ```
30
+
31
+ Every Atlas payload that names a type carries it as `"klass"`, so the method
32
+ lives on the shared `Mash` and covers `find`, `find_many`, `ancestors` nodes
33
+ and the Solr digests. `klass` itself is unchanged.
34
+
35
+ ### Added — `Resource.unresolvable!`
36
+
37
+ Every `Resource` subclass now registers itself when it is defined, under its
38
+ class name and its snake-cased wire key. A subclass that Atlas never names in
39
+ a payload opts out by calling `unresolvable!` in its body. `Compilation` is
40
+ the only one.
41
+
42
+ ### Added — `mods_editable?`
43
+
44
+ `true` for `Work`, `Collection` and `Community`, `false` for every other
45
+ type. It matches Atlas's `ResourcesController::TYPED_IVARS`, which Atlas
46
+ does not expose on the wire.
47
+
48
+ ## 1.26.0
49
+
50
+ These bindings need Atlas 0.6.221 or later.
51
+
52
+ ### Added — Darwin Core records
53
+
54
+ ```ruby
55
+ AtlasRb::Work.dwc(id, kind = "xml") # "xml", "json" or "html"; nil on 404
56
+ AtlasRb::Resource.put_dwc(id, xml_path, origin: nil)
57
+ AtlasRb::Resource.delete_dwc(id) # true; raises NotFoundError on 404
58
+ AtlasRb::Resource.dwc_versions(id)
59
+ AtlasRb::Resource.dwc_version(id, version_id)
60
+ ```
61
+
62
+ A Work can hold one Simple Darwin Core record. `Work.dwc` returns the stored
63
+ XML by default, the display block for `"html"`, and the Work unwrapped from
64
+ `"work"` for `"json"`, with the terms under `"dwc"`. `put_dwc` replaces the
65
+ whole record and raises `ResourceError` on Atlas's `422` when the document
66
+ breaks a shape rule. `delete_dwc` withdraws the record without purging it, so
67
+ the next `put_dwc` restores it. The version calls mirror `mods_versions` and
68
+ `mods_version`.
69
+
70
+ ### Documented — `metadata_formats` on the Work
71
+
72
+ `Work.find` results now carry `metadata_formats`, the additional records the
73
+ Work holds, such as `["dwc"]`. No code changed: the gem passes the whole Work
74
+ through.
75
+
3
76
  ## 1.25.0
4
77
 
5
78
  These bindings need Atlas 0.6.214 or later.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- atlas_rb (1.25.0)
4
+ atlas_rb (1.27.0)
5
5
  faraday (~> 2.7)
6
6
  faraday-follow_redirects (~> 0.3.0)
7
7
  faraday-multipart (~> 1)
@@ -38,10 +38,13 @@ GEM
38
38
  base64
39
39
  logger (1.7.0)
40
40
  multipart-post (2.4.1)
41
- net-http (0.9.1)
41
+ net-http (0.10.0)
42
+ net-protocol (>= 0.3.0)
42
43
  uri (>= 0.11.1)
43
44
  net-http-persistent (4.0.8)
44
45
  connection_pool (>= 2.2.4, < 4)
46
+ net-protocol (0.4.0)
47
+ timeout
45
48
  rake (13.2.1)
46
49
  rspec (3.13.0)
47
50
  rspec-core (~> 3.13.0)
@@ -56,7 +59,8 @@ GEM
56
59
  diff-lcs (>= 1.2.0, < 2.0)
57
60
  rspec-support (~> 3.13.0)
58
61
  rspec-support (3.13.3)
59
- uri (1.1.1)
62
+ timeout (0.6.1)
63
+ uri (1.1.2)
60
64
  yard (0.9.43)
61
65
 
62
66
  PLATFORMS
data/README.md CHANGED
@@ -527,6 +527,35 @@ array (mirroring `Collection.children`); the two mutations return the list
527
527
  Resolving those Collections' full contents is a Cerberus/Solr concern —
528
528
  this gem never queries the index.
529
529
 
530
+ ### Darwin Core records
531
+
532
+ A Work can hold one Simple Darwin Core record beside its MODS. The Work's
533
+ `metadata_formats` says whether it does, so check that before reading:
534
+
535
+ ```ruby
536
+ work = AtlasRb::Work.find("w-789")
537
+ if work["metadata_formats"].include?("dwc")
538
+ AtlasRb::Work.dwc("w-789", "html") # the display block
539
+ AtlasRb::Work.dwc("w-789", "json")["dwc"]["catalogNumber"] # => "S27880"
540
+ AtlasRb::Work.dwc("w-789") # the stored XML
541
+ end
542
+
543
+ AtlasRb::Resource.put_dwc("w-789", "/tmp/dwc.xml", origin: "xml_loader")
544
+ AtlasRb::Resource.delete_dwc("w-789") # withdraws; the next put_dwc restores
545
+
546
+ history = AtlasRb::Resource.dwc_versions("w-789")
547
+ AtlasRb::Resource.dwc_version("w-789", history["versions"].last["version_id"])
548
+ ```
549
+
550
+ `Work.dwc` answers `nil` when the Work holds no record. `put_dwc` raises
551
+ `ResourceError` on a refused document; its body's `error` names the rule
552
+ (`malformed_xml`, `invalid_root`, `record_count` or `duplicate_term`). Atlas
553
+ checks shape only, so validate against `tdwg_dwc_simple.xsd` before you call.
554
+ `delete_dwc` purges nothing: Atlas keeps the document and its history.
555
+
556
+ The two version calls behave as `mods_versions` and `mods_version` do, under
557
+ the same admin gate.
558
+
530
559
  The two mutations raise the same way `reparent` does — `LinkedMemberError`
531
560
  on a structural `422` (carrying the envelope's `error` code as `#code`) and
532
561
  `ForbiddenError` on a `403` — instead of swallowing the envelope.
@@ -540,6 +569,8 @@ write. The whole surface:
540
569
  | Call | Endpoint |
541
570
  |---|---|
542
571
  | `Resource.put_mods(id, xml_path, origin:)` | `PUT /resources/{id}/mods` |
572
+ | `Resource.put_dwc(id, xml_path, origin:)` | `PUT /resources/{id}/dwc` (Works only) |
573
+ | `Resource.delete_dwc(id)` | `DELETE /resources/{id}/dwc` (Works only) |
543
574
  | `Resource.set_permissions(id, acl)` | `PATCH /resources/{id}/permissions` |
544
575
  | `Resource.set_thumbnails(id, thumbnail:, thumbnail_2x:, preview:)` | `PATCH /resources/{id}/thumbnails` |
545
576
  | `Resource.reparent(id, parent_id)` | `PATCH /resources/{id}/parent` |
@@ -584,34 +615,37 @@ Writes that address something only one type has stay typed — `create`, the
584
615
  Work-only lifecycle and derivative writes, `Collection.set_featured`, and the
585
616
  FileSet, Blob and Compilation writes.
586
617
 
587
- ### Resolving one id of unknown type (`Resource.find` and `Resource.class_for`)
618
+ ### Resolving one id of unknown type (`Resource.find` and `resource_class`)
588
619
 
589
620
  When a NOID arrives as runtime data and you do not know its type, resolve
590
621
  it generically and dispatch on the reported type:
591
622
 
592
623
  ```ruby
593
624
  found = AtlasRb::Resource.find("b8gtjvk")
594
- found["klass"] # => "FileSet"
595
- klass = AtlasRb::Resource.class_for(found["klass"]) # => AtlasRb::FileSet
596
- klass.find(found["resource"]["id"])
597
- ```
598
-
599
- `class_for` is the supported way to turn a type string into a class. Do not
600
- `const_get` into the `AtlasRb` namespace: the mapping is a stated, closed
601
- set (`Resource::TYPE_MAP`), not a naming rule — `Blob`'s route is `/files/`,
602
- and a wire key like `file_set` is not the class name.
603
-
604
- It accepts every spelling the DRS stack produces for a type, because a
605
- caller cannot tell which one it is holding:
606
-
607
- | Spelling | Where it comes from |
608
- |---------------|--------------------------------------------|
609
- | `"file_set"` | Atlas's JSON wire key |
610
- | `"FileSet"` | Solr's `internal_resource`, and `find`'s `"klass"` |
611
- | `"File_set"` | a `"klass"` read from a gem before 1.17.0 |
612
-
613
- An unknown type raises `ArgumentError` rather than returning `nil`, so a
614
- type this gem does not model fails where the cause is.
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.
615
649
 
616
650
  **What the resolver covers.** Atlas answers `/resources/:id` for its
617
651
  Valkyrie-backed types only: `Work`, `Collection`, `Community`, `FileSet`,
@@ -651,7 +685,7 @@ result = AtlasRb::Resource.search("whaling logbook", type: "Work", per_page: 50)
651
685
  result.pagination.total # => 12
652
686
  hit = result.results.first
653
687
  hit.title # => "Whaling logbook, 1851"
654
- AtlasRb::Resource.class_for(hit.klass).find(hit.noid)
688
+ hit.resource_class.find(hit.noid)
655
689
  ```
656
690
 
657
691
  It searches Works, Collections, Communities and People, most relevant first.
@@ -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.
@@ -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.
@@ -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/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
@@ -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 {Resource.class_for} turns it into the class.
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 defines no class
53
- # for. See {Resource.class_for}.
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" => class_for(result.first[0]).name.split("::").last,
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, which {class_for} accepts, so a caller loads the full resource
171
- # with `class_for(hit.klass).find(hit.noid)`.
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
- # AtlasRb::Resource.class_for(hit.klass).find(hit.noid)
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
@@ -419,6 +486,47 @@ module AtlasRb
419
486
  ))
420
487
  end
421
488
 
489
+ # List the retained versions of a Work's Darwin Core record.
490
+ #
491
+ # The same envelope as {mods_versions}, under the same admin gate, with
492
+ # attribution drawn from the Darwin Core edits only. A Work with no record,
493
+ # or an id that is not a Work, comes back as `{ "versions" => [] }`.
494
+ #
495
+ # @param id [String] the Work's NOID.
496
+ # @param nuid [String, nil] optional acting user's NUID.
497
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
498
+ # header.
499
+ # @return [AtlasRb::Mash, nil] the envelope, with `"resource_id"` and a
500
+ # newest-first `"versions"` array; `nil` on a `404`.
501
+ # @raise [AtlasRb::ResourceError] on any non-2xx other than `404` / `410`,
502
+ # including the `403` a non-admin caller gets.
503
+ # @example
504
+ # AtlasRb::Resource.dwc_versions("w-789")["versions"].first["source"] # => "dwc"
505
+ def self.dwc_versions(id, nuid: nil, on_behalf_of: nil)
506
+ read_body(connection({}, nuid, on_behalf_of: on_behalf_of)
507
+ .get('/resources/' + id + '/dwc/versions')) { |body| AtlasRb::Mash.new(body) }
508
+ end
509
+
510
+ # Fetch a Work's Darwin Core document as of a specific version.
511
+ #
512
+ # XML only, because Atlas overwrites the JSON access copy in place. Pass a
513
+ # `version_id` from {dwc_versions}.
514
+ #
515
+ # @param id [String] the Work's NOID.
516
+ # @param version_id [String] an OCFL version label, e.g. `"v3"`.
517
+ # @param nuid [String, nil] optional acting user's NUID.
518
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
519
+ # header.
520
+ # @return [String, nil] the raw XML; `nil` on a `404` (unknown version, or
521
+ # no record ever written).
522
+ # @raise [AtlasRb::ResourceError] on any non-2xx other than `404` / `410`.
523
+ # @example
524
+ # AtlasRb::Resource.dwc_version("w-789", "v3")
525
+ def self.dwc_version(id, version_id, nuid: nil, on_behalf_of: nil)
526
+ read_raw(connection({}, nuid, on_behalf_of: on_behalf_of)
527
+ .get('/resources/' + id + '/dwc/versions/' + version_id))
528
+ end
529
+
422
530
  # Shared read path behind every typed `find`: perform a single-resource
423
531
  # `GET` and hand the response to {AtlasRb::FaradayHelper#read_body}, which
424
532
  # owns the status mapping (`404` → `nil`, `410` → the tombstone body, any
@@ -485,21 +593,25 @@ module AtlasRb
485
593
  # @raise [AtlasRb::ResourceError] on any other non-2xx except `410`.
486
594
  # @api private
487
595
  def self.write_resource(resp)
596
+ guard_write(resp)
597
+ JSON.parse(resp.body)
598
+ end
599
+ private_class_method :write_resource
600
+
601
+ # The status half of {write_resource}, for a write that answers with no
602
+ # body, such as a `204`.
603
+ def self.guard_write(resp)
488
604
  if resp.status == 404
489
605
  raise AtlasRb::NotFoundError.new("#{request_target(resp)} → 404 (no such resource)", response: resp)
490
606
  end
607
+ return if resp.success? || resp.status == 410
491
608
 
492
- unless resp.success? || resp.status == 410
493
- raise AtlasRb::ResourceError.new("#{request_target(resp)} → #{resp.status}: #{resp.body}", response: resp)
494
- end
495
-
496
- JSON.parse(resp.body)
609
+ raise AtlasRb::ResourceError.new("#{request_target(resp)} → #{resp.status}: #{resp.body}", response: resp)
497
610
  end
498
- private_class_method :write_resource
611
+ private_class_method :guard_write
499
612
 
500
- # The multipart body behind {Work.update} / {Collection.update} /
501
- # {Community.update}: the MODS document, plus the optional `origin` tag
502
- # naming the surface that produced the edit.
613
+ # The multipart body behind {put_mods} and {put_dwc}: the XML document,
614
+ # plus the optional `origin` tag naming the surface that produced the edit.
503
615
  #
504
616
  # Atlas records `origin` verbatim on the audit event and never branches on
505
617
  # it, so a host names its own surfaces (Cerberus sends `metadata_form`,
@@ -512,13 +624,13 @@ module AtlasRb
512
624
  # @param origin [String, nil] the edit-origin tag, or `nil` to send none.
513
625
  # @return [Hash] the multipart payload.
514
626
  # @api private
515
- def self.mods_upload_payload(xml_path, origin)
627
+ def self.xml_upload_payload(xml_path, origin)
516
628
  payload = { binary: Faraday::Multipart::FilePart.new(File.open(xml_path),
517
629
  "application/xml",
518
630
  File.basename(xml_path)) }
519
631
  payload[:origin] = origin.to_s unless origin.nil? || origin.to_s.empty?
520
632
  payload
521
633
  end
522
- private_class_method :mods_upload_payload
634
+ private_class_method :xml_upload_payload
523
635
  end
524
636
  end
@@ -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, and
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`
@@ -52,10 +50,61 @@ module AtlasRb
52
50
  def self.put_mods(id, xml_path, nuid: nil, on_behalf_of: nil, origin: nil)
53
51
  unwrap(write_resource(
54
52
  multipart(nuid, on_behalf_of: on_behalf_of)
55
- .put('/resources/' + id + '/mods', mods_upload_payload(xml_path, origin))
53
+ .put('/resources/' + id + '/mods', xml_upload_payload(xml_path, origin))
56
54
  ))
57
55
  end
58
56
 
57
+ # Replace a Work's Darwin Core record.
58
+ #
59
+ # `PUT`: the document replaces the whole record. The first call creates it,
60
+ # and a call after {delete_dwc} restores it. Each call appends a version.
61
+ # Atlas checks the document's shape; validating it against
62
+ # `tdwg_dwc_simple.xsd` is the caller's job.
63
+ #
64
+ # @param id [String] the Work's NOID.
65
+ # @param xml_path [String] path to the Simple Darwin Core XML to upload.
66
+ # @param nuid [String, nil] optional acting user's NUID.
67
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
68
+ # header.
69
+ # @param origin [String, nil] the editing surface to record on the audit
70
+ # event, e.g. `"xml_loader"`. Omitted from the body when nil.
71
+ # @return [AtlasRb::Mash] the Work's `id` and its terms under `"dwc"`.
72
+ # @raise [AtlasRb::NotFoundError] on `404` — no such id, or not a Work.
73
+ # @raise [AtlasRb::ResourceError] on any other non-2xx. A refused document
74
+ # is a `422` whose body names the rule in `error`: `malformed_xml`,
75
+ # `invalid_root`, `record_count` or `duplicate_term`.
76
+ #
77
+ # @example
78
+ # AtlasRb::Resource.put_dwc("xsj3xmz", "/tmp/dwc.xml", origin: "xml_loader")
79
+ def self.put_dwc(id, xml_path, nuid: nil, on_behalf_of: nil, origin: nil)
80
+ unwrap(write_resource(
81
+ multipart(nuid, on_behalf_of: on_behalf_of)
82
+ .put('/resources/' + id + '/dwc', xml_upload_payload(xml_path, origin))
83
+ ))
84
+ end
85
+
86
+ # Withdraw a Work's Darwin Core record.
87
+ #
88
+ # Nothing is purged: Atlas keeps the document and its version history, and
89
+ # the next {put_dwc} restores the record. Until then {Work.dwc} answers
90
+ # `nil` and the Work's `metadata_formats` leaves out `"dwc"`.
91
+ #
92
+ # @param id [String] the Work's NOID.
93
+ # @param nuid [String, nil] optional acting user's NUID.
94
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
95
+ # header.
96
+ # @return [true] when the record was withdrawn.
97
+ # @raise [AtlasRb::NotFoundError] on `404` — the Work holds no record, or
98
+ # the id is not a Work.
99
+ # @raise [AtlasRb::ResourceError] on any other non-2xx.
100
+ #
101
+ # @example
102
+ # AtlasRb::Resource.delete_dwc("xsj3xmz")
103
+ def self.delete_dwc(id, nuid: nil, on_behalf_of: nil)
104
+ guard_write(connection({}, nuid, on_behalf_of: on_behalf_of).delete('/resources/' + id + '/dwc'))
105
+ true
106
+ end
107
+
59
108
  # Adjust a resource's ACL.
60
109
  #
61
110
  # `PATCH`, and every key merges: a key you omit keeps its stored value.
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.
@@ -613,6 +618,39 @@ module AtlasRb
613
618
  ))
614
619
  end
615
620
 
621
+ # Fetch the Work's Darwin Core record in the requested format.
622
+ #
623
+ # A Work holds a record only when its `metadata_formats` includes `"dwc"`,
624
+ # so check that before calling: a Work with no record answers `nil` here.
625
+ #
626
+ # @param id [String] the Work ID.
627
+ # @param kind [String] one of `"xml"` (default), `"json"` or `"html"`.
628
+ # `"xml"` is the stored Simple Darwin Core document, byte for byte, for a
629
+ # standalone download. `"html"` is the display block, the counterpart of
630
+ # `Work.mods(id, "html")`; each `<dt>` names its term in `data-term`.
631
+ # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
632
+ # path it is signed into the assertion `sub`; on the BYO-JWT (`ATLAS_JWT`)
633
+ # path it is ignored (identity lives in the token).
634
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
635
+ # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
636
+ # omitted.
637
+ # @return [String, AtlasRb::Mash, nil] the raw body for `"xml"` and
638
+ # `"html"`; for `"json"`, the Work unwrapped from its `"work"` key, with
639
+ # the terms under `"dwc"`.
640
+ #
641
+ # `nil` when Atlas answers `404` — the Work holds no record (or it was
642
+ # withdrawn), or the id is not a Work.
643
+ # @raise [AtlasRb::ResourceError] on any non-2xx other than `404` / `410`,
644
+ # such as the `403` for a Work the caller may not read.
645
+ # @example
646
+ # AtlasRb::Work.dwc("w-789", "json")["dwc"]["catalogNumber"] # => "S27880"
647
+ def self.dwc(id, kind = 'xml', nuid: nil, on_behalf_of: nil)
648
+ body = read_raw(connection({}, nuid, on_behalf_of: on_behalf_of).get(ROUTE + id + "/dwc.#{kind}"))
649
+ return body unless kind.to_s == 'json' && body
650
+
651
+ AtlasRb::Mash.new(JSON.parse(body))['work']
652
+ end
653
+
616
654
  # List the Collections a Work is a *linked* member of.
617
655
  #
618
656
  # Wraps `GET /works/<id>/linked_members`. Linked membership is the DAG
data/lib/atlas_rb.rb CHANGED
@@ -33,9 +33,9 @@ require_relative "atlas_rb/blob"
33
33
  require_relative "atlas_rb/delegate"
34
34
  require_relative "atlas_rb/compilation"
35
35
  require_relative "atlas_rb/person"
36
- # Must follow every Resource subclass above: it names them in a map.
37
- require_relative "atlas_rb/resource_types"
38
- # Same ordering requirement as resource_types: the typed writes delegate here.
36
+ # Every resolvable type has registered itself by now.
37
+ AtlasRb::Resource::TYPES.freeze
38
+ # Must follow every Resource subclass above: the typed writes delegate here.
39
39
  require_relative "atlas_rb/resource_writes"
40
40
  require_relative "atlas_rb/user"
41
41
  require_relative "atlas_rb/admin"
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.25.0
4
+ version: 1.27.0
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-01 00:00:00.000000000 Z
11
+ date: 2026-10-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -178,7 +178,6 @@ files:
178
178
  - lib/atlas_rb/middleware/raise_on_stale_resource.rb
179
179
  - lib/atlas_rb/person.rb
180
180
  - lib/atlas_rb/resource.rb
181
- - lib/atlas_rb/resource_types.rb
182
181
  - lib/atlas_rb/resource_writes.rb
183
182
  - lib/atlas_rb/system.rb
184
183
  - 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