atlas_rb 1.16.0 → 1.17.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: ed32d60a2b3c2a562ee64d095a266c6b45848122ce4d75a0201c6736aeb52c2f
4
- data.tar.gz: 229e5722c7b2036e490df5c06a5ad7afe496164fe7105a2ee232b7cb64f5d970
3
+ metadata.gz: 3a418fc64c12474c618d8bcffbaf01f116f0bb7c80c999f4c3bd4514cf47c879
4
+ data.tar.gz: ebfa9f0d7e85b884f6f96a16dd59861d01a0d03bbf6a25c94a0e92665add8d2d
5
5
  SHA512:
6
- metadata.gz: e63d081a6b2f67297e95e2feb361ed232e585b3c22a84b68c0d35dd0a816aae1cfca0106613ddc9f097644bd8b02a098285ce48f7ee930126102037c2dff808c
7
- data.tar.gz: 70931eed6c2ceaf7fd901a07005df4dec7570a5f90649b0bfb277826f927922bfb3ad30f73a226254d8059fb1c82f4e114e11a4a7977ddd1ff7d7e291e846d8f
6
+ metadata.gz: 1e2541b872b918215dd73155ae55fc8d841a7da7fce77f6b984dc82187148dce0a4698ee2b8aaa561b659d1fd253f2077f614cb8d592edaa54a10a343ea8664a
7
+ data.tar.gz: c0bb9652d3a1cfe08485d6057f23db0e7c493fefe4bfd23be7796e825e1a0bc998b4b02e625c2475e87b247ffc49ffb229469c35adb3b770010bda4cb42c1220
data/.version CHANGED
@@ -1 +1 @@
1
- 1.16.0
1
+ 1.17.0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.17.0
4
+
5
+ ### Fixed — `Resource.find` emitted a type string this namespace cannot resolve
6
+
7
+ `find` derived its `"klass"` with `String#capitalize` over Atlas's JSON key,
8
+ and `capitalize` is not the inverse of a Ruby class name once the type has two
9
+ words. Atlas answers `{"file_set": {...}}`, and `"file_set".capitalize` is
10
+ `"File_set"`. There is no `AtlasRb::File_set`.
11
+
12
+ The doc comment stated the convention as a promise — *"the resource type,
13
+ capitalized (e.g. `"Work"`)"* — and `"Work"` is the one shape where
14
+ capitalizing happens to work. Every other consumer read that as "`const_get`
15
+ this", so a FileSet NOID put a `NameError` in the caller's stack:
16
+
17
+ ```
18
+ uninitialized constant AtlasRb::File_set
19
+ ```
20
+
21
+ `find` now reports the class's own name, so a FileSet is `"FileSet"`.
22
+
23
+ **This changes one value on the wire**: `"File_set"` becomes `"FileSet"`. That
24
+ is the spelling Solr already carries as `internal_resource`, so a consumer fed
25
+ by both sources now sees one vocabulary instead of two.
26
+
27
+ ### Added — `Resource.class_for`, the supported type-string → class lookup
28
+
29
+ ```ruby
30
+ AtlasRb::Resource.class_for("file_set") # => AtlasRb::FileSet
31
+ AtlasRb::Resource.class_for("FileSet") # => AtlasRb::FileSet
32
+ AtlasRb::Resource.class_for("File_set") # => AtlasRb::FileSet
33
+ ```
34
+
35
+ Closing the loop on `find` used to mean reaching into this namespace by string
36
+ on a naming convention the gem never promised. `class_for` reads a stated,
37
+ closed set — `Resource::TYPE_MAP`, covering the eight `Resource` subclasses —
38
+ and accepts all three spellings the DRS stack produces for a type: Atlas's
39
+ wire key, Solr's `internal_resource`, and the pre-1.17.0 `"klass"` a caller
40
+ can still be holding.
41
+
42
+ The set is stated rather than derived because there is no rule to derive:
43
+ `Blob`'s `ROUTE` is `/files/`.
44
+
45
+ An unrecognized type raises `ArgumentError` — never `nil`, and never
46
+ `const_get`, which would resolve an arbitrary constant in this namespace. Same
47
+ argument `Middleware::RaiseOnReadError` settled for the read path: keep the
48
+ failure where the cause is.
49
+
50
+ ### Documented — what the generic resolver actually covers
51
+
52
+ Atlas answers `/resources/:id` for its Valkyrie-backed types only: `Work`,
53
+ `Collection`, `Community`, `FileSet`, `Blob`, `Delegate` and `Person`. A
54
+ `Compilation` is an ActiveRecord row in Atlas rather than a Valkyrie resource,
55
+ so the resolver never finds one, though `Compilation.find` does. `nil` from
56
+ `Resource.find` is therefore ambiguous: it means "no such id" **or** "that id
57
+ names a Compilation". `Resource.find`'s YARD and the README both say so now.
58
+
3
59
  ## 1.16.0
4
60
 
5
61
  ### Fixed — every read binding consults the HTTP status before it reads the body
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- atlas_rb (1.16.0)
4
+ atlas_rb (1.17.0)
5
5
  faraday (~> 2.7)
6
6
  faraday-follow_redirects (~> 0.3.0)
7
7
  faraday-multipart (~> 1)
data/README.md CHANGED
@@ -531,6 +531,42 @@ The two mutations raise the same way `reparent` does — `LinkedMemberError`
531
531
  on a structural `422` (carrying the envelope's `error` code as `#code`) and
532
532
  `ForbiddenError` on a `403` — instead of swallowing the envelope.
533
533
 
534
+ ### Resolving one id of unknown type (`Resource.find` and `Resource.class_for`)
535
+
536
+ When a NOID arrives as runtime data and you do not know its type, resolve
537
+ it generically and dispatch on the reported type:
538
+
539
+ ```ruby
540
+ found = AtlasRb::Resource.find("b8gtjvk")
541
+ found["klass"] # => "FileSet"
542
+ klass = AtlasRb::Resource.class_for(found["klass"]) # => AtlasRb::FileSet
543
+ klass.find(found["resource"]["id"])
544
+ ```
545
+
546
+ `class_for` is the supported way to turn a type string into a class. Do not
547
+ `const_get` into the `AtlasRb` namespace: the mapping is a stated, closed
548
+ set (`Resource::TYPE_MAP`), not a naming rule — `Blob`'s route is `/files/`,
549
+ and a wire key like `file_set` is not the class name.
550
+
551
+ It accepts every spelling the DRS stack produces for a type, because a
552
+ caller cannot tell which one it is holding:
553
+
554
+ | Spelling | Where it comes from |
555
+ |---------------|--------------------------------------------|
556
+ | `"file_set"` | Atlas's JSON wire key |
557
+ | `"FileSet"` | Solr's `internal_resource`, and `find`'s `"klass"` |
558
+ | `"File_set"` | a `"klass"` read from a gem before 1.17.0 |
559
+
560
+ An unknown type raises `ArgumentError` rather than returning `nil`, so a
561
+ type this gem does not model fails where the cause is.
562
+
563
+ **What the resolver covers.** Atlas answers `/resources/:id` for its
564
+ Valkyrie-backed types only: `Work`, `Collection`, `Community`, `FileSet`,
565
+ `Blob`, `Delegate` and `Person`. A `Compilation` is an ActiveRecord row in
566
+ Atlas rather than a Valkyrie resource, so the generic resolver never finds
567
+ one — use `AtlasRb::Compilation.find` for those. That makes `nil` ambiguous
568
+ here: it means "no such id" **or** "that id names a Compilation".
569
+
534
570
  ### Batch resolve (`Resource.find_many`)
535
571
 
536
572
  When you have a *set* of NOIDs and only need each one's title / klass /
@@ -220,6 +220,9 @@ module AtlasRb
220
220
  # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
221
221
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
222
222
  # omitted.
223
+ # @param origin [String, nil] free-text tag naming the surface that made this
224
+ # edit (e.g. `"metadata_form"`, `"xml_editor"`). Atlas records it verbatim
225
+ # on the audit event; omit it and the event carries no origin.
223
226
  # @return [Hash] the parsed JSON response from the patch.
224
227
  # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
225
228
  # resource, so the write did not happen.
@@ -228,12 +231,13 @@ module AtlasRb
228
231
  #
229
232
  # @example
230
233
  # AtlasRb::Collection.update("col-456", "/tmp/collection-mods.xml")
231
- def self.update(id, xml_path, nuid: nil, on_behalf_of: nil)
232
- payload = { binary: Faraday::Multipart::FilePart.new(File.open(xml_path),
233
- "application/xml",
234
- File.basename(xml_path)) }
234
+ #
235
+ # @example Recording which editing surface made the change
236
+ # AtlasRb::Collection.update("col-456", "/tmp/collection-mods.xml", origin: "xml_editor")
237
+ def self.update(id, xml_path, nuid: nil, on_behalf_of: nil, origin: nil)
235
238
  AtlasRb::Mash.new(write_resource(
236
- multipart(nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id, payload)
239
+ multipart(nuid, on_behalf_of: on_behalf_of)
240
+ .patch(ROUTE + id, mods_upload_payload(xml_path, origin))
237
241
  ))
238
242
  end
239
243
 
@@ -199,6 +199,9 @@ module AtlasRb
199
199
  # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
200
200
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
201
201
  # omitted.
202
+ # @param origin [String, nil] free-text tag naming the surface that made this
203
+ # edit (e.g. `"metadata_form"`, `"xml_editor"`). Atlas records it verbatim
204
+ # on the audit event; omit it and the event carries no origin.
202
205
  # @return [Hash] the parsed JSON response from the patch.
203
206
  # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
204
207
  # resource, so the write did not happen.
@@ -207,12 +210,13 @@ module AtlasRb
207
210
  #
208
211
  # @example
209
212
  # AtlasRb::Community.update("c-123", "/tmp/community-mods.xml")
210
- def self.update(id, xml_path, nuid: nil, on_behalf_of: nil)
211
- payload = { binary: Faraday::Multipart::FilePart.new(File.open(xml_path),
212
- "application/xml",
213
- File.basename(xml_path)) }
213
+ #
214
+ # @example Recording which editing surface made the change
215
+ # AtlasRb::Community.update("c-123", "/tmp/community-mods.xml", origin: "xml_editor")
216
+ def self.update(id, xml_path, nuid: nil, on_behalf_of: nil, origin: nil)
214
217
  AtlasRb::Mash.new(write_resource(
215
- multipart(nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id, payload)
218
+ multipart(nuid, on_behalf_of: on_behalf_of)
219
+ .patch(ROUTE + id, mods_upload_payload(xml_path, origin))
216
220
  ))
217
221
  end
218
222
 
@@ -25,6 +25,15 @@ module AtlasRb
25
25
  # method splits that into a normalized `{ "klass" => ..., "resource" => ... }`
26
26
  # pair so callers can dispatch on type.
27
27
  #
28
+ # ## What the resolver covers
29
+ #
30
+ # Atlas answers `/resources/:id` for its Valkyrie-backed types only:
31
+ # {Work}, {Collection}, {Community}, {FileSet}, {Blob}, {Delegate} and
32
+ # {Person}. A {Compilation} is an ActiveRecord row in Atlas rather than a
33
+ # Valkyrie resource, so the resolver never finds one — use
34
+ # {Compilation.find} for those. That makes `nil` ambiguous: it means "no
35
+ # such id" **or** "that id names a Compilation".
36
+ #
28
37
  # @param id [String] an Atlas resource ID of any type.
29
38
  # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
30
39
  # path it is signed into the assertion `sub`; on the BYO-JWT (`ATLAS_JWT`)
@@ -34,10 +43,14 @@ module AtlasRb
34
43
  # omitted.
35
44
  # @return [Hash{String => String, Hash}, nil] hash with two keys, or `nil`
36
45
  # when the id resolves to nothing (`404`):
37
- # - `"klass"` — the resource type, capitalized (e.g. `"Work"`).
46
+ # - `"klass"` — the resource type as its class name, e.g. `"Work"`,
47
+ # `"FileSet"`. That is the spelling Solr carries as
48
+ # `internal_resource`, and {Resource.class_for} turns it into the class.
38
49
  # - `"resource"` — the resource payload as a Hash.
39
50
  # @raise [AtlasRb::ResourceError] on any non-2xx other than `404` / `410` (e.g. an
40
51
  # 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}.
41
54
  #
42
55
  # @example Polymorphic lookup
43
56
  # AtlasRb::Resource.find("abc123")
@@ -46,7 +59,10 @@ module AtlasRb
46
59
  result = fetch_resource('/resources/' + id, nuid: nuid, on_behalf_of: on_behalf_of)
47
60
  return nil if result.nil?
48
61
 
49
- AtlasRb::Mash.new("klass" => result.first[0].capitalize,
62
+ # The class's own name, never `capitalize` over the wire key: `capitalize`
63
+ # answers `"File_set"` for a `file_set`, which is not a constant in this
64
+ # namespace, and callers resolve this string to a class.
65
+ AtlasRb::Mash.new("klass" => class_for(result.first[0]).name.split("::").last,
50
66
  "resource" => result.first[1])
51
67
  end
52
68
 
@@ -431,5 +447,29 @@ module AtlasRb
431
447
  JSON.parse(resp.body)
432
448
  end
433
449
  private_class_method :write_resource
450
+
451
+ # The multipart body behind {Work.update} / {Collection.update} /
452
+ # {Community.update}: the MODS document, plus the optional `origin` tag
453
+ # naming the surface that produced the edit.
454
+ #
455
+ # Atlas records `origin` verbatim on the audit event and never branches on
456
+ # it, so a host names its own surfaces (Cerberus sends `metadata_form`,
457
+ # `advanced_form`, `xml_editor`). A `nil` origin is left out of the body
458
+ # entirely rather than sent empty, so the audit event of a host that does
459
+ # not set one looks exactly like every event recorded before the field
460
+ # existed.
461
+ #
462
+ # @param xml_path [String] path to a MODS XML file on disk.
463
+ # @param origin [String, nil] the edit-origin tag, or `nil` to send none.
464
+ # @return [Hash] the multipart payload.
465
+ # @api private
466
+ def self.mods_upload_payload(xml_path, origin)
467
+ payload = { binary: Faraday::Multipart::FilePart.new(File.open(xml_path),
468
+ "application/xml",
469
+ File.basename(xml_path)) }
470
+ payload[:origin] = origin.to_s unless origin.nil? || origin.to_s.empty?
471
+ payload
472
+ end
473
+ private_class_method :mods_upload_payload
434
474
  end
435
475
  end
@@ -0,0 +1,60 @@
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
data/lib/atlas_rb/work.rb CHANGED
@@ -358,6 +358,9 @@ module AtlasRb
358
358
  # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
359
359
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
360
360
  # omitted.
361
+ # @param origin [String, nil] free-text tag naming the surface that made this
362
+ # edit (e.g. `"metadata_form"`, `"xml_editor"`). Atlas records it verbatim
363
+ # on the audit event; omit it and the event carries no origin.
361
364
  # @return [Hash] the parsed JSON response from the patch.
362
365
  # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
363
366
  # resource, so the write did not happen.
@@ -366,12 +369,13 @@ module AtlasRb
366
369
  #
367
370
  # @example
368
371
  # AtlasRb::Work.update("w-789", "/tmp/work-mods.xml")
369
- def self.update(id, xml_path, nuid: nil, on_behalf_of: nil)
370
- payload = { binary: Faraday::Multipart::FilePart.new(File.open(xml_path),
371
- "application/xml",
372
- File.basename(xml_path)) }
372
+ #
373
+ # @example Recording which editing surface made the change
374
+ # AtlasRb::Work.update("w-789", "/tmp/work-mods.xml", origin: "xml_editor")
375
+ def self.update(id, xml_path, nuid: nil, on_behalf_of: nil, origin: nil)
373
376
  AtlasRb::Mash.new(write_resource(
374
- multipart(nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id, payload)
377
+ multipart(nuid, on_behalf_of: on_behalf_of)
378
+ .patch(ROUTE + id, mods_upload_payload(xml_path, origin))
375
379
  ))
376
380
  end
377
381
 
data/lib/atlas_rb.rb CHANGED
@@ -33,6 +33,8 @@ 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"
36
38
  require_relative "atlas_rb/user"
37
39
  require_relative "atlas_rb/admin"
38
40
  require_relative "atlas_rb/admin/work"
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.16.0
4
+ version: 1.17.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-09-09 00:00:00.000000000 Z
11
+ date: 2026-09-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -180,6 +180,7 @@ files:
180
180
  - lib/atlas_rb/middleware/raise_on_stale_resource.rb
181
181
  - lib/atlas_rb/person.rb
182
182
  - lib/atlas_rb/resource.rb
183
+ - lib/atlas_rb/resource_types.rb
183
184
  - lib/atlas_rb/system.rb
184
185
  - lib/atlas_rb/system/token.rb
185
186
  - lib/atlas_rb/system/user.rb