Class: AtlasRb::Work

Inherits:
Resource show all
Defined in:
lib/atlas_rb/work.rb

Overview

The bibliographic unit in Atlas — an article, thesis, dataset, image, etc.

A Work belongs to exactly one Collection and aggregates one or more FileSets, each of which holds binary content via a Blob. MODS metadata is attached at the Work level.

See also: Collection, FileSet, Blob.

Constant Summary collapse

ROUTE =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

Atlas REST endpoint prefix for this resource.

"/works/"

Class Method Summary collapse

Methods inherited from Resource

history, permissions, preview

Methods included from FaradayHelper

#connection, #multipart, #system_connection

Class Method Details

.add_linked_member(work_id, collection_id, nuid: nil, on_behalf_of: nil) ⇒ Array<String>

Add a linked membership: surface a Work in an additional Collection.

Wraps POST /works/<id>/linked_members with a collection_id body. This does not move the Work — its structural parent (a_member_of) is untouched; the Collection is added to a_linked_member_of. Atlas enforces two-sided authorization (edit on the Work and the target Collection) and the structural guards, surfacing failures as a 422. Permissions are never changed by this call.

Examples:

AtlasRb::Work.add_linked_member("w-789", "col-456")
# => ["col-456"]

Parameters:

  • work_id (String)

    the Work ID.

  • collection_id (String)

    the Collection to link the Work into.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID, forwarded as the User: header. Required for cerberus-token requests; legacy bearer tokens still resolve without it.

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Array<String>)

    the Work's full set of linked Collection noids after the add — the affected sub-resource, so no follow-up linked_members GET is needed.

Raises:

  • (AtlasRb::StaleResourceError)

    if Atlas reports an optimistic-lock conflict that exhausted its internal retry budget (HTTP 409 with error: "stale_resource").



446
447
448
449
450
451
# File 'lib/atlas_rb/work.rb', line 446

def self.add_linked_member(work_id, collection_id, nuid: nil, on_behalf_of: nil)
  JSON.parse(
    connection({ collection_id: collection_id }, nuid, on_behalf_of: on_behalf_of)
      .post(ROUTE + work_id + '/linked_members')&.body
  )
end

.assets(id, nuid: nil, on_behalf_of: nil) ⇒ Array<AtlasRb::Mash>

List the assets attached to a Work — Blobs and Delegates alike.

Useful for building download UIs — the response includes enough to render each entry's display name, size or uri, and download URL. The shape is polymorphic: Blob-backed entries carry fields like size, while Delegate-backed entries carry uri. Callers should duck-type on the field they need rather than expecting a single schema.

Examples:

AtlasRb::Work.assets("w-789").each { |a| puts a.label }

Parameters:

  • id (String)

    the Work ID.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID, forwarded as the User: header. Required for cerberus-token requests; legacy bearer tokens still resolve without it.

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Array<AtlasRb::Mash>)

    the listing from GET /works/<id>/assets, one entry per attached asset.



362
363
364
365
366
# File 'lib/atlas_rb/work.rb', line 362

def self.assets(id, nuid: nil, on_behalf_of: nil)
  JSON.parse(
    connection({}, nuid, on_behalf_of: on_behalf_of).get(ROUTE + id + '/assets')&.body
  ).map { |entry| AtlasRb::Mash.new(entry) }
end

.complete(id, nuid: nil, on_behalf_of: nil) ⇒ Faraday::Response

Mark a Work complete.

Cerberus's bulk-deposit job calls this once it has confirmed all expected children (FileSets / Blobs) are deposited. Atlas's monitoring query GET /works?in_progress=true then drops this Work from the "stuck" list.

Idempotent on the server: calling complete on an already-complete Work is a no-op — Atlas simply re-saves with in_progress: false. Atlas does not currently stamp a completed_by audit field; the nuid: parameter is plumbed through for parity with the other lifecycle bindings and in case Atlas adds completion audit later.

Examples:

AtlasRb::Work.complete("w-789")

Parameters:

  • id (String)

    the Work ID.

  • nuid (String, nil) (defaults to: nil)

    optional NUID of the acting user.

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Faraday::Response)

    the raw response. Status 200 on success.

Raises:

  • (AtlasRb::StaleResourceError)

    if Atlas reports an optimistic-lock conflict that exhausted its internal retry budget (HTTP 409 with error: "stale_resource").



216
217
218
# File 'lib/atlas_rb/work.rb', line 216

def self.complete(id, nuid: nil, on_behalf_of: nil)
  connection({}, nuid, on_behalf_of: on_behalf_of).post(ROUTE + id + '/complete')
end

.create(id, xml_path = nil, idempotency_key: nil, nuid: nil, on_behalf_of: nil, depositor: nil) ⇒ Hash

Create a new Work in an existing Collection.

Note: unlike Community.create and Collection.create, the id parameter here is the parent Collection ID. The underlying request uses the collection_id query param rather than parent_id.

Examples:

Empty work, metadata to be added later

AtlasRb::Work.create("col-456")

Work seeded from MODS

AtlasRb::Work.create("col-456", "/tmp/work-mods.xml")

Retry-safe bulk-deposit create

key = SecureRandom.uuid
AtlasRb::Work.create("col-456", idempotency_key: key)

Proxy deposit — librarian uploads on behalf of a researcher

AtlasRb::Work.create("col-456", depositor: "000000123")

Parameters:

  • id (String)

    the parent Collection ID.

  • xml_path (String, nil) (defaults to: nil)

    optional path to a MODS XML file. When given, the Work is created and immediately patched with the metadata in the file.

  • idempotency_key (String, nil) (defaults to: nil)

    optional UUID. A repeat call with the same key returns the originally-created Work instead of creating a new one (or 410 if it has since been tombstoned, or 410 with no body if it has been hard-deleted). Keys are scoped to the acting user and only apply to the initial POST /works — the optional follow-up PATCH/GET when xml_path is given do not carry the key. The caller (e.g. Cerberus's Solid Queue job) generates and persists the UUID; this gem does not mint keys.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID, forwarded as the User: header. Required for cerberus-token requests; legacy bearer tokens still resolve without it.

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

  • depositor (String, nil) (defaults to: nil)

    optional NUID to stamp on the new Work's depositor field. When omitted, Atlas defaults the depositor to the acting user (nuid:); this kwarg is the proxy / batch escape hatch where the librarian who uploaded the Work is distinct from the person it should be attributed to. The acting user becomes the Work's proxy_uploader. The depositor is immutable post-create; there is no setter on the update surface.

Returns:

  • (Hash)

    the created Work payload (post-update if xml_path was supplied).



118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/atlas_rb/work.rb', line 118

def self.create(id, xml_path = nil, idempotency_key: nil, nuid: nil,
                on_behalf_of: nil, depositor: nil)
  params = { collection_id: id }
  params[:depositor] = depositor if depositor
  result = AtlasRb::Mash.new(JSON.parse(
    connection(params, nuid,
               on_behalf_of: on_behalf_of, idempotency_key: idempotency_key).post(ROUTE)&.body
  ))["work"]
  return result unless xml_path.present?

  update(result["id"], xml_path, nuid: nuid, on_behalf_of: on_behalf_of)
  find(result["id"], nuid: nuid, on_behalf_of: on_behalf_of)
end

.find(id, nuid: nil, on_behalf_of: nil) ⇒ Hash

Fetch a single Work by ID.

Examples:

AtlasRb::Work.find("w-789")
# => { "id" => "w-789", "title" => "An Article", ... }

Parameters:

  • id (String)

    the Work ID.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID, forwarded as the User: header. Required for cerberus-token requests; legacy bearer tokens still resolve without it.

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header (acting-as / view-as). Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Hash)

    the "work" object, already unwrapped from the JSON response.



31
32
33
34
35
# File 'lib/atlas_rb/work.rb', line 31

def self.find(id, nuid: nil, on_behalf_of: nil)
  AtlasRb::Mash.new(JSON.parse(
    connection({}, nuid, on_behalf_of: on_behalf_of).get(ROUTE + id)&.body
  ))["work"]
end

.linked_members(id, nuid: nil, on_behalf_of: nil) ⇒ Array<String>

List the Collections a Work is a linked member of.

Wraps GET /works/<id>/linked_members. Linked membership is the DAG overlay — a Work has exactly one structural parent (a_member_of, set by create / reparent) but may additionally appear in any number of other Collections as a linked member (a_linked_member_of). This returns just those linked Collection noids; the structural parent is not included.

Examples:

AtlasRb::Work.linked_members("w-789")
# => ["col-456", "col-457"]

Parameters:

  • id (String)

    the Work ID.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID, forwarded as the User: header. Required for cerberus-token requests; legacy bearer tokens still resolve without it.

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Array<String>)

    linked Collection noids (possibly empty). The shape mirrors Collection.children — a bare array of ids, not an envelope.



413
414
415
416
417
# File 'lib/atlas_rb/work.rb', line 413

def self.linked_members(id, nuid: nil, on_behalf_of: nil)
  JSON.parse(
    connection({}, nuid, on_behalf_of: on_behalf_of).get(ROUTE + id + '/linked_members')&.body
  )
end

.list(in_progress: nil, page: nil, per_page: nil, nuid: nil, on_behalf_of: nil) ⇒ AtlasRb::Mash

List Works, paginated.

Wraps GET /works. Returns the full pagination envelope rather than a bare array so callers can page through results — the shape matches Community.children and Collection.children.

Examples:

Find stuck deposits

AtlasRb::Work.list(in_progress: true)

Page through all works

AtlasRb::Work.list(page: 2, per_page: 50)

Parameters:

  • in_progress (Boolean, nil) (defaults to: nil)

    when set, filter to Works whose in_progress flag matches. Omit (or pass nil) for "all works".

  • page (Integer, nil) (defaults to: nil)

    1-indexed page number.

  • per_page (Integer, nil) (defaults to: nil)

    page size override.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID, forwarded as the User: header. Required for cerberus-token requests; legacy bearer tokens still resolve without it.

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (AtlasRb::Mash)

    { "works" => [...], "pagination" => {...} }. Each entry in "works" is a Work summary (id, title, description, in_progress).



62
63
64
65
66
67
68
69
70
# File 'lib/atlas_rb/work.rb', line 62

def self.list(in_progress: nil, page: nil, per_page: nil, nuid: nil, on_behalf_of: nil)
  params = {}
  params[:in_progress] = in_progress unless in_progress.nil?
  params[:page]        = page        if page
  params[:per_page]    = per_page    if per_page
  AtlasRb::Mash.new(JSON.parse(
    connection(params, nuid, on_behalf_of: on_behalf_of).get(ROUTE)&.body
  ))
end

.metadata(id, values, nuid: nil, on_behalf_of: nil) ⇒ Hash

Patch individual descriptive-metadata fields without uploading a full MODS document.

Scoped to user-authored descriptive metadata only. Programmatic writes of machine-set Delegate URIs (thumbnails, image derivatives) have their own purpose-specific endpoints — see set_thumbnails and set_image_derivatives.

Examples:

AtlasRb::Work.("w-789", title: "Revised Title")

Parameters:

  • id (String)

    the Work ID.

  • values (Hash)

    field-level metadata updates.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID, forwarded as the User: header. Required for cerberus-token requests; legacy bearer tokens still resolve without it.

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Hash)

    the parsed JSON response.



263
264
265
266
267
# File 'lib/atlas_rb/work.rb', line 263

def self.(id, values, nuid: nil, on_behalf_of: nil)
  AtlasRb::Mash.new(JSON.parse(
    connection({ metadata: values }, nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id)&.body
  ))
end

.mods(id, kind = nil, nuid: nil, on_behalf_of: nil) ⇒ String

Fetch the Work's MODS representation in the requested format.

Examples:

AtlasRb::Work.mods("w-789", "html")

Parameters:

  • id (String)

    the Work ID.

  • kind (String, nil) (defaults to: nil)

    one of "json" (default), "html", or "xml".

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID, forwarded as the User: header. Required for cerberus-token requests; legacy bearer tokens still resolve without it.

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (String)

    the raw response body in the requested format.



383
384
385
386
387
388
# File 'lib/atlas_rb/work.rb', line 383

def self.mods(id, kind = nil, nuid: nil, on_behalf_of: nil)
  # json default, html, xml
  connection({}, nuid, on_behalf_of: on_behalf_of).get(
    ROUTE + id + '/mods' + (kind.present? ? ".#{kind}" : '')
    )&.body
end

.remove_linked_member(work_id, collection_id, nuid: nil, on_behalf_of: nil) ⇒ Array<String>

Remove a linked membership: drop a Work from an additional Collection.

Wraps DELETE /works/<id>/linked_members/<collection_id> — the Collection is passed as a path segment, not a body. This removes the Collection from the Work's a_linked_member_of; the structural parent (a_member_of) is untouched. Atlas enforces the same two-sided authorization as add_linked_member. Removing a link that does not exist is a server-side concern; this binding simply forwards the call.

Examples:

AtlasRb::Work.remove_linked_member("w-789", "col-456")
# => []

Parameters:

  • work_id (String)

    the Work ID.

  • collection_id (String)

    the linked Collection to remove.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID, forwarded as the User: header. Required for cerberus-token requests; legacy bearer tokens still resolve without it.

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Array<String>)

    the Work's remaining linked Collection noids after the removal (possibly empty).

Raises:

  • (AtlasRb::StaleResourceError)

    if Atlas reports an optimistic-lock conflict that exhausted its internal retry budget (HTTP 409 with error: "stale_resource").



479
480
481
482
483
484
# File 'lib/atlas_rb/work.rb', line 479

def self.remove_linked_member(work_id, collection_id, nuid: nil, on_behalf_of: nil)
  JSON.parse(
    connection({}, nuid, on_behalf_of: on_behalf_of)
      .delete(ROUTE + work_id + '/linked_members/' + collection_id)&.body
  )
end

.reparent(id, new_collection_id, nuid: nil, on_behalf_of: nil) ⇒ Hash

Move a Work to a different parent Collection.

Wraps PATCH /works/<id>/parent with a parent_id of the new Collection. This changes the Work's single structural home (a_member_of) — distinct from add_linked_member, which adds an additional linked membership without moving the Work. Atlas re-parents the Work and synchronously updates its ancestry index; the structural rules (type, cycle, tombstone guards) are enforced server-side and surface as a 422.

Note: like create, the destination here is a Collection, but the underlying request still uses the shared parent_id body key (not collection_id) — every re-parent endpoint posts { parent_id }.

Examples:

AtlasRb::Work.reparent("w-789", "col-999")

Parameters:

  • id (String)

    the Work ID to move.

  • new_collection_id (String)

    the destination Collection ID.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID, forwarded as the User: header. Required for cerberus-token requests; legacy bearer tokens still resolve without it.

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Hash)

    the updated "work" object, already unwrapped — the same shape find returns, reflecting the new a_member_of.

Raises:

  • (AtlasRb::StaleResourceError)

    if Atlas reports an optimistic-lock conflict that exhausted its internal retry budget (HTTP 409 with error: "stale_resource").



162
163
164
165
166
167
# File 'lib/atlas_rb/work.rb', line 162

def self.reparent(id, new_collection_id, nuid: nil, on_behalf_of: nil)
  AtlasRb::Mash.new(JSON.parse(
    connection({ parent_id: new_collection_id }, nuid, on_behalf_of: on_behalf_of)
      .patch(ROUTE + id + '/parent')&.body
  ))["work"]
end

.set_image_derivatives(id, small: nil, medium: nil, large: nil, nuid: nil, on_behalf_of: nil) ⇒ AtlasRb::Mash

Attach the three image-derivative Delegate URIs to a Work.

Sibling of set_thumbnails for the small_image / medium_image / large_image Delegate roles. Atlas dispatches each URI to its matching role via DelegateUpdater. The resulting Delegates are downloadable and surface through assets for the downloads UI. Missing keys are left untouched server-side; only the URIs you pass are upserted.

Examples:

AtlasRb::Work.set_image_derivatives(
  "w-789",
  small:  "https://iiif.example.edu/iiif/3/abc.jp2/full/800,/0/default.jpg",
  medium: "https://iiif.example.edu/iiif/3/abc.jp2/full/1600,/0/default.jpg",
  large:  "https://iiif.example.edu/iiif/3/abc.jp2/full/full/0/default.jpg"
)

Parameters:

  • id (String)

    the Work ID.

  • small (String, nil) (defaults to: nil)

    IIIF URI for the small derivative.

  • medium (String, nil) (defaults to: nil)

    IIIF URI for the medium derivative.

  • large (String, nil) (defaults to: nil)

    IIIF URI for the large derivative.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID, forwarded as the User: header. Required for cerberus-token requests; legacy bearer tokens still resolve without it.

Returns:

Raises:

  • (AtlasRb::StaleResourceError)

    if Atlas reports an optimistic-lock conflict that exhausted its internal retry budget (HTTP 409 with error: "stale_resource").



333
334
335
336
337
338
339
# File 'lib/atlas_rb/work.rb', line 333

def self.set_image_derivatives(id, small: nil, medium: nil, large: nil, nuid: nil, on_behalf_of: nil)
  body = { small: small, medium: medium, large: large }.compact
  AtlasRb::Mash.new(JSON.parse(
    connection({}, nuid, on_behalf_of: on_behalf_of)
      .patch(ROUTE + id + '/image_derivatives', JSON.dump(body))&.body
  ))
end

.set_thumbnails(id, thumbnail: nil, thumbnail_2x: nil, preview: nil, nuid: nil, on_behalf_of: nil) ⇒ AtlasRb::Mash

Attach the three thumbnail/preview Delegate URIs to a Work.

Purpose-specific PATCH for the thumbnail_image / thumbnail_image_2x / preview_image Delegate roles. Atlas dispatches each URI to its matching role via DelegateUpdater. Distinct from metadata — these are machine-set IIIF URIs, not user-authored descriptive content. Missing keys are left untouched server-side; only the URIs you pass are upserted.

Examples:

AtlasRb::Work.set_thumbnails(
  "w-789",
  thumbnail:    "https://iiif.example.edu/iiif/3/abc.jp2/full/!85,85/0/default.jpg",
  thumbnail_2x: "https://iiif.example.edu/iiif/3/abc.jp2/full/!170,170/0/default.jpg",
  preview:      "https://iiif.example.edu/iiif/3/abc.jp2/full/500,/0/default.jpg"
)

Parameters:

  • id (String)

    the Work ID.

  • thumbnail (String, nil) (defaults to: nil)

    IIIF URI for the ~85² thumbnail.

  • thumbnail_2x (String, nil) (defaults to: nil)

    IIIF URI for the ~170² 2x thumbnail.

  • preview (String, nil) (defaults to: nil)

    IIIF URI for the ~500w preview image.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID, forwarded as the User: header. Required for cerberus-token requests; legacy bearer tokens still resolve without it.

Returns:

Raises:

  • (AtlasRb::StaleResourceError)

    if Atlas reports an optimistic-lock conflict that exhausted its internal retry budget (HTTP 409 with error: "stale_resource").



297
298
299
300
301
302
303
# File 'lib/atlas_rb/work.rb', line 297

def self.set_thumbnails(id, thumbnail: nil, thumbnail_2x: nil, preview: nil, nuid: nil, on_behalf_of: nil)
  body = { thumbnail: thumbnail, thumbnail_2x: thumbnail_2x, preview: preview }.compact
  AtlasRb::Mash.new(JSON.parse(
    connection({}, nuid, on_behalf_of: on_behalf_of)
      .patch(ROUTE + id + '/thumbnails', JSON.dump(body))&.body
  ))
end

.tombstone(id, nuid: nil, on_behalf_of: nil) ⇒ Faraday::Response

Tombstone (withdraw) a Work.

The Work remains in Atlas storage along with its FileSets and Blobs, but is marked as withdrawn: search and show pages return a withdrawn stub for every user. Unlike Communities and Collections, Works are always tombstoneable regardless of how many files they hold — the FileSets and Blobs ride along.

Examples:

AtlasRb::Work.tombstone("w-789", nuid: "000000002")

Parameters:

  • id (String)

    the Work ID.

  • nuid (String) (defaults to: nil)

    the acting user's NUID, stamped on the resource as tombstoned_by for audit purposes.

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Faraday::Response)

    the raw response.



187
188
189
# File 'lib/atlas_rb/work.rb', line 187

def self.tombstone(id, nuid: nil, on_behalf_of: nil)
  connection({}, nuid, on_behalf_of: on_behalf_of).post(ROUTE + id + '/tombstone')
end

.update(id, xml_path, nuid: nil, on_behalf_of: nil) ⇒ Hash

Replace a Work's metadata by uploading a MODS XML document.

Examples:

AtlasRb::Work.update("w-789", "/tmp/work-mods.xml")

Parameters:

  • id (String)

    the Work ID.

  • xml_path (String)

    path to a MODS XML file on disk.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID, forwarded as the User: header. Required for cerberus-token requests; legacy bearer tokens still resolve without it.

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Hash)

    the parsed JSON response from the patch.



234
235
236
237
238
239
240
241
# File 'lib/atlas_rb/work.rb', line 234

def self.update(id, xml_path, nuid: nil, on_behalf_of: nil)
  payload = { binary: Faraday::Multipart::FilePart.new(File.open(xml_path),
                                                       "application/xml",
                                                       File.basename(xml_path)) }
  AtlasRb::Mash.new(JSON.parse(
    multipart(nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id, payload)&.body
  ))
end