Class: AtlasRb::Blob

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

Overview

The binary content backing a FileSet (or attached directly to a Work).

Blobs are the bytes-on-disk layer of the hierarchy. Operations on this class deal with raw octet streams: uploading new content, replacing content on an existing Blob, and streaming downloads via a chunk handler so very large files don't have to be buffered in memory.

See also: Work, FileSet.

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.

"/files/"

Constants included from FaradayHelper

FaradayHelper::ASSERTION_AUDIENCE, FaradayHelper::ASSERTION_ISSUER, FaradayHelper::ASSERTION_TTL, FaradayHelper::INSTRUMENTATION_EVENT

Class Method Summary collapse

Methods inherited from Resource

descendant_works, find_many, history, mods, mods_version, mods_versions, permissions, preview

Methods included from FaradayHelper

#connection, #multipart, #system_connection, #with_file_part

Class Method Details

.ancestry(id, nuid: nil, on_behalf_of: nil) ⇒ AtlasRb::Mash

Resolve a content Blob to its parent FileSet and containing Work noids.

Wraps GET /files/<id>/ancestry. The download path is keyed only by the blob id, so a consumer recording a download/stream impression against the containing Work resolves it here — instead of threading the work noid through the download URL. Reads on the Blob floor (no admin gate). An unknown id yields a 404 (raw Faraday response); either value is nil when unresolvable (e.g. an orphan blob with no FileSet parent).

Examples:

AtlasRb::Blob.ancestry("b-321")
# => { "file_set" => "fs-654", "work" => "w-789" }

Parameters:

  • id (String)

    the Blob ID.

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

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • 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)

    { "file_set" => "<noid>", "work" => "<noid>" } (either value nil when unresolvable).



66
67
68
69
70
# File 'lib/atlas_rb/blob.rb', line 66

def self.ancestry(id, nuid: nil, on_behalf_of: nil)
  AtlasRb::Mash.new(JSON.parse(
    connection({}, nuid, on_behalf_of: on_behalf_of).get("#{ROUTE}#{id}/ancestry")&.body
  ))
end

.content(id, range: nil, nuid: nil, on_behalf_of: nil) {|chunk| ... } ⇒ Hash

Stream the Blob's binary content through a caller-supplied block.

The body is not buffered — each chunk Faraday receives is yielded to chunk_handler immediately, making this safe for files larger than available memory.

Pass range: (e.g. "bytes=0-1048575") to forward an HTTP Range header; Atlas answers 206 Partial Content and the chunks yielded are just the requested slice. The returned hash exposes both the response status (200 vs 206) and the response headers — so a caller proxying to a browser media element can relay Content-Range, Content-Length and Accept-Ranges verbatim and reproduce the 206.

Examples:

Stream the whole body to disk

File.open("/tmp/out.pdf", "wb") do |f|
  res = AtlasRb::Blob.content("b-321") { |chunk| f.write(chunk) }
  puts res[:headers]["content-type"]
end

Relay a browser's Range request as a 206

res = AtlasRb::Blob.content("b-321", range: "bytes=0-1048575") { |c| out << c }
res[:status]                       # => 206
res[:headers]["content-range"]     # => "bytes 0-1048575/52428800"

Parameters:

  • id (String)

    the Blob ID.

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

    optional HTTP byte range ("bytes=START-END", "bytes=START-", or "bytes=-SUFFIX"). Omitted ⇒ whole-body 200.

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

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • 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.

Yield Parameters:

  • chunk (String)

    the next chunk of binary data.

Returns:

  • (Hash)

    { status: Integer, headers: Faraday::Utils::Headers } for GET /files/<id>/content (headers is case-insensitive, hash-like).



123
124
125
126
127
128
129
# File 'lib/atlas_rb/blob.rb', line 123

def self.content(id, range: nil, nuid: nil, on_behalf_of: nil, &chunk_handler)
  response = connection({}, nuid, on_behalf_of: on_behalf_of).get("#{ROUTE}#{id}/content") do |req|
    req.headers["Range"] = range if range
    req.options.on_data = proc { |chunk, _bytes_received, _env| chunk_handler.call(chunk) }
  end
  { status: response.status, headers: response.headers }
end

.create(id, blob_path, original_filename, expected_digest: nil, idempotency_key: nil, nuid: nil, on_behalf_of: nil) ⇒ Hash

Note:

Streams the file (FD closed deterministically); a multi-GB upload is not buffered in memory. See FaradayHelper#with_file_part.

Upload a new Blob attached to a Work.

original_filename is preserved separately from the upload's File.basename(blob_path) because the on-disk path is often a temp file name (RackMultipart...tmp) — Atlas needs the user-facing name for download UX.

Examples:

AtlasRb::Blob.create("w-789", "/tmp/upload.tmp", "final_thesis.pdf")
# => { "id" => "b-321", "original_filename" => "final_thesis.pdf", ... }

Retry-safe bulk-deposit create with fixity verification

key = SecureRandom.uuid
AtlasRb::Blob.create("w-789", "/tmp/upload.tmp", "thesis.pdf",
                     idempotency_key: key, expected_digest: "sha256:#{sha}")

Parameters:

  • id (String)

    the parent Work ID.

  • blob_path (String)

    path to the binary file on disk to upload.

  • original_filename (String)

    the user-facing filename Atlas should record (e.g. "final_thesis.pdf").

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

    optional UUID. A repeat call with the same key returns the originally-created Blob instead of creating a new one. See Work.create for full semantics.

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

    optional verify-on-ingest checksum, "<algorithm>:<hexvalue>" (sha512/sha256/sha1/md5, e.g. "sha256:abc…"). Atlas hashes the uploaded bytes before persisting and raises FixityMismatchError (HTTP 422) on a mismatch or an unsupported algorithm — nothing is left behind on rejection.

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

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • 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 created "blob" payload, including its "id" and "digest" (the recorded fixity digest, "sha512:<hex>").

Raises:



176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/atlas_rb/blob.rb', line 176

def self.create(id, blob_path, original_filename, expected_digest: nil,
                idempotency_key: nil, nuid: nil, on_behalf_of: nil)
  with_file_part(blob_path) do |part|
    payload = { work_id: id, original_filename: original_filename, binary: part }
    payload[:expected_digest] = expected_digest if expected_digest

    AtlasRb::Mash.new(write_resource(
      multipart(nuid, on_behalf_of: on_behalf_of, idempotency_key: idempotency_key)
        .post(ROUTE, payload)
    ))['blob']
  end
end

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

Delete a Blob: the metadata record and the bytes.

Atlas removes the whole OCFL object, so every retained revision goes, not only the current one — versions and rollback have nothing left to work with afterwards. Unrecoverable, and admin-only. The Blob is also unlinked from its FileSet, whose METS is rebuilt.

Examples:

AtlasRb::Blob.destroy("b-321")

Parameters:

  • id (String)

    the Blob ID.

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

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • 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 delete response.



207
208
209
# File 'lib/atlas_rb/blob.rb', line 207

def self.destroy(id, nuid: nil, on_behalf_of: nil)
  connection({}, nuid, on_behalf_of: on_behalf_of).delete(ROUTE + id)
end

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

Fetch a single Blob's metadata record (not its bytes — see content).

Examples:

AtlasRb::Blob.find("b-321")
# => { "id" => "b-321", "original_filename" => "scan.pdf",
#      "digest" => "sha512:9f86d0…", ... }

Parameters:

  • id (String)

    the Blob ID.

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

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • 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, nil)

    the "blob" object, already unwrapped — typically includes "id", "original_filename", "size", "digest" (the recorded fixity digest "sha512:<hex>", or nil for a Blob with no held bytes — reconciliation compares this against the v1 manifest without re-downloading), and a download URL — or nil when the Blob does not exist (404).

Raises:

  • (AtlasRb::ResourceError)

    on any non-2xx other than 404 / 410 (e.g. an auth/validation error envelope), carrying Atlas's status + body.



39
40
41
42
# File 'lib/atlas_rb/blob.rb', line 39

def self.find(id, nuid: nil, on_behalf_of: nil)
  body = fetch_resource(ROUTE + id, nuid: nuid, on_behalf_of: on_behalf_of)
  body && AtlasRb::Mash.new(body)['blob']
end

.find_many_versions(ids, nuid: nil, on_behalf_of: nil) ⇒ Array<AtlasRb::Mash>

Read binary version history for many Blobs in one round-trip.

Wraps Atlas's POST /files/find_many_versions — the batch counterpart to versions, returning one envelope of exactly that shape per Blob. Use it anywhere a set of Blob noids would otherwise be resolved with a versions-per-noid fan-out (the admin file-manage listing, which reads every replaceable Blob on a Work): one HTTP call instead of N.

The ids travel in the request body, so the list is not bounded by URL length. The result is unordered and may be shorter than the input — an id that resolves to nothing, or to a resource that is not a Blob, is dropped silently. Index by "blob_id"; do not assume positional correspondence with ids.

Server admin-gates this exactly like versions (the descriptors expose the same edit attribution), so 401 / 403 surface as raw Faraday responses. The grant is class-wide, so nothing is dropped for authorization — a dropped id is an unresolvable one.

Examples:

Render a Work's files with their histories in two calls

assets  = AtlasRb::Work.assets(work_noid).reject { |a| a[:uri].present? }
history = AtlasRb::Blob.find_many_versions(assets.map(&:noid))
                       .index_by { |h| h["blob_id"] }
history[assets.first.noid]["versions"].first["revision"] # => 3

Parameters:

  • ids (Array<String>)

    Blob NOIDs.

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

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • 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>)

    one versions-shaped envelope per resolved Blob ("blob_id" plus a reverse-chronological "versions" array); empty when none resolved.



328
329
330
331
332
333
# File 'lib/atlas_rb/blob.rb', line 328

def self.find_many_versions(ids, nuid: nil, on_behalf_of: nil)
  JSON.parse(
    connection({}, nuid, on_behalf_of: on_behalf_of)
      .post("#{ROUTE}find_many_versions", JSON.dump(ids: Array(ids)))&.body
  ).map { |envelope| AtlasRb::Mash.new(envelope) }
end

.rollback(id, version_id, nuid: nil, on_behalf_of: nil) ⇒ AtlasRb::Mash

Roll a Blob back to a prior version.

Wraps POST /files/<id>/rollback. Atlas promotes the given version to current by appending its bytes again as a NEW revision — so rollback is itself non-destructive (it becomes vN+1 with the bytes of vN) and the Blob NOID is preserved. OCFL dedups the identical content, so no bytes are recopied. Avoids a full round-trip of the bytes back through the caller (vs. re-streaming version_content into update).

Pass a version_id obtained from versions. Atlas answers an unknown id or version with a 404, which raises NotFoundError — a write that did not happen must not read like one that did.

Examples:

AtlasRb::Blob.rollback("b-321", "v1")

Parameters:

  • id (String)

    the Blob ID.

  • version_id (String)

    the OCFL version label to reinstate, e.g. "v1".

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

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • 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)

    the updated "blob" payload (NOID unchanged, "digest" refreshed to the reinstated bytes).

Raises:



403
404
405
406
407
408
# File 'lib/atlas_rb/blob.rb', line 403

def self.rollback(id, version_id, nuid: nil, on_behalf_of: nil)
  AtlasRb::Mash.new(write_resource(
    connection({}, nuid, on_behalf_of: on_behalf_of)
      .post("#{ROUTE}#{id}/rollback", JSON.dump(version_id: version_id))
  ))['blob']
end

.update(id, blob_path, expected_digest: nil, idempotency_key: nil, nuid: nil, on_behalf_of: nil) ⇒ Hash

Note:

Streams the file with the FD closed deterministically — see create.

Replace the bytes of an existing Blob in-place.

The Blob ID is preserved; only the underlying content changes. The original filename is not updated by this call — use a new create if you need a different original_filename.

Examples:

AtlasRb::Blob.update("b-321", "/tmp/revised.pdf")

Retry-safe replace

AtlasRb::Blob.update("b-321", "/tmp/revised.pdf", idempotency_key: SecureRandom.uuid)

Parameters:

  • id (String)

    the Blob ID.

  • blob_path (String)

    path to the replacement binary on disk.

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

    optional verify-on-ingest checksum, "<algorithm>:<hexvalue>". 422 (FixityMismatchError) on mismatch.

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

    optional UUID. A double-submit of the replace with the same key returns the existing Blob instead of minting a second OCFL version — without it a retried replace appends a duplicate revision. See Work.create for full semantics.

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

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • 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 (the updated "blob", with a refreshed "digest" for the new revision).

Raises:



247
248
249
250
251
252
253
254
255
256
257
# File 'lib/atlas_rb/blob.rb', line 247

def self.update(id, blob_path, expected_digest: nil, idempotency_key: nil, nuid: nil, on_behalf_of: nil)
  with_file_part(blob_path) do |part|
    payload = { binary: part }
    payload[:expected_digest] = expected_digest if expected_digest

    AtlasRb::Mash.new(write_resource(
      multipart(nuid, on_behalf_of: on_behalf_of, idempotency_key: idempotency_key)
        .patch(ROUTE + id, payload)
    ))
  end
end

.version_content(id, version_id, nuid: nil, on_behalf_of: nil) {|chunk| ... } ⇒ Hash

Stream the bytes of a prior version of a Blob through a block.

Wraps GET /files/<id>/versions/<version_id>/content — the version-pinned twin of content, and the read half of "download the superseded file". Like content, the body is not buffered: each chunk is yielded to chunk_handler immediately (safe for files larger than memory), and the response headers are captured and returned.

Pass a version_id obtained from versions (an opaque OCFL vN label); only labels the history surfaced are addressable. An unknown id or version yields a 404 (raw Faraday response).

Examples:

Download a superseded version to disk

File.open("/tmp/old.pdf", "wb") do |f|
  AtlasRb::Blob.version_content("b-321", "v1") { |chunk| f.write(chunk) }
end

Parameters:

  • id (String)

    the Blob ID.

  • version_id (String)

    an OCFL version label from versions, e.g. "v1".

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

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • 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.

Yield Parameters:

  • chunk (String)

    the next chunk of binary data.

Returns:

  • (Hash)

    the response headers from the version-content request.



362
363
364
365
366
367
368
369
370
371
# File 'lib/atlas_rb/blob.rb', line 362

def self.version_content(id, version_id, nuid: nil, on_behalf_of: nil, &chunk_handler)
  headers = {}
  connection({}, nuid, on_behalf_of: on_behalf_of).get("#{ROUTE}#{id}/versions/#{version_id}/content") do |req|
    req.options.on_data = proc do |chunk, _bytes_received, env|
      headers = env.response_headers if headers.empty? && env
      chunk_handler.call(chunk)
    end
  end
  headers
end

.versions(id, nuid: nil, on_behalf_of: nil) ⇒ AtlasRb::Mash

List a Blob's retained binary version history.

Wraps Atlas's GET /files/<id>/versions — the binary counterpart to Resource.mods_versions. Returns a reverse-chronological (newest first) envelope: one descriptor per retained content revision, each carrying its OCFL version_id label, the file_identifier appended for that revision, the created timestamp, the digest/size recorded at that version, the stable original_filename, and actor attribution (actor_nuid / on_behalf_of_nuid, null when no audit event correlates).

Server admin-gates this endpoint (it exposes edit attribution), so 401 / 403 surface as raw Faraday responses, matching Resource.mods_versions. An unknown Blob id yields a 404.

Examples:

history = AtlasRb::Blob.versions("b-321")
history["versions"].first["version_id"] # => "v5"
history["versions"].first["digest"]      # => "sha512:9f86d0…"

Parameters:

  • id (String)

    the Blob ID.

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

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • 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)

    the parsed envelope, with "blob_id" and a "versions" array (reverse chronological).



287
288
289
290
291
# File 'lib/atlas_rb/blob.rb', line 287

def self.versions(id, nuid: nil, on_behalf_of: nil)
  AtlasRb::Mash.new(JSON.parse(
    connection({}, nuid, on_behalf_of: on_behalf_of).get("#{ROUTE}#{id}/versions")&.body
  ))
end

.work(id, nuid: nil, on_behalf_of: nil) ⇒ String?

Convenience over ancestry: the containing Work's noid for a content Blob (or nil when unresolvable). The shape Cerberus's impression-capture job wants — roll a download up to its Work from the blob id alone.

Examples:

AtlasRb::Blob.work("b-321") # => "w-789"

Parameters:

  • id (String)

    the Blob ID.

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

    optional acting user's NUID (see ancestry).

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

    optional On-Behalf-Of NUID (see ancestry).

Returns:

  • (String, nil)

    the containing Work's noid, or nil when unresolvable.



83
84
85
# File 'lib/atlas_rb/blob.rb', line 83

def self.work(id, nuid: nil, on_behalf_of: nil)
  ancestry(id, nuid: nuid, on_behalf_of: on_behalf_of)['work']
end