A document attachment: the metadata CouchDB keeps in a document's
_attachments stubs, plus the content once fetched or supplied.
If the only tool you have is CouchDB, then everything looks like
{:ok, :relax}
CouchDB describes attachments in two places, see
https://docs.couchdb.org/en/stable/api/document/attachments.html and
the _attachments section of the document API:
- inside the document, as stubs with the content type, size, digest and
the revision number (
revpos) they were added in, or inline with the content base64 encoded underdata - at
/{db}/{docid}/{attname}, as raw bytes with their own HTTP verbs
This struct covers both. data is nil for a stub and a binary once
the content is known. Sofa.Doc keeps its attachments as a map of
name to %Sofa.Attachment{} and converts to and from the wire format,
so a document built with Sofa.Doc.attach/4 saves its attachments
inline in one request.
Reads take the database handle first, as elsewhere in Sofa. Writes take the document first and return it with its new revision, so uploads pipe:
doc
|> Sofa.Attachment.put!(db, "recipe.txt", File.read!("recipe.txt"))
|> Sofa.Attachment.put!(db, "video.mp4", {:file, "video.mp4"})Compression
CouchDB stores attachments of its compressible_types, such as
text/plain, gzipped, and computes the digest over those stored bytes.
get/4 verifies the bytes against that digest and decompresses them,
recording encoding and encoded_length, so data is always the plain
content. The digest of such an attachment therefore does not match its
plain content, which verified?/1 accounts for.
Examples
iex> att = Sofa.Attachment.new("recipe.txt", "1. Cook spaghetti\n")
iex> {att.content_type, att.length}
{"text/plain", 18}
iex> Sofa.Attachment.new("recipe.txt", "...") |> Sofa.Attachment.to_stub()
%{"content_type" => "text/plain", "data" => "Li4u"}
Summary
Types
Content to upload: in memory, from a file, or any enumerable of binaries
A content digest as CouchDB reports it: md5- and the base64 hash
A document, or just its id where no revision is involved
Why a request failed, as the usual atoms, or the response for anything else
Functions
Deletes an attachment, and returns the document with its new revision
and without the stub. A revision is required, from the document or as
rev:. batch: :ok defers the write as the docs describe.
Bang! version of delete/4. Returns the updated document or raises Sofa.Error.
The md5- digest CouchDB reports for content.
Builds an attachment from one entry of a document's _attachments.
Fetches an attachment's content with the metadata from the response
headers. The bytes are verified against the digest CouchDB sends as the
ETag, giving {:error, :digest_mismatch} if they differ, and an
attachment CouchDB stored gzipped is then decompressed, with encoding
and encoded_length recording that.
Bang! version of get/4. Returns the attachment or raises Sofa.Error.
Fetches an attachment's metadata from the headers of a HEAD request:
content type, size and the digest CouchDB uses as the ETag.
An attachment with content, ready to be attached to a document.
Uploads content as an attachment in its own request, and returns the document with its new revision and the attachment's stub, ready for the next write. The document comes first so that uploads pipe.
Bang! version of put/5. Returns the updated document or raises Sofa.Error.
The _attachments entry for an attachment: inline, with the content
base64 encoded, when it has content, otherwise a stub that tells CouchDB
to keep the attachment it already has. Keys CouchDB did not report are
left out.
Whether the content matches the digest. false when either is missing,
or when CouchDB stored the content compressed, as the digest then covers
the gzipped bytes. get/4 verifies those while it still has them.
Types
@type content() :: binary() | {:file, Path.t()} | Enumerable.t()
Content to upload: in memory, from a file, or any enumerable of binaries
@type digest() :: String.t()
A content digest as CouchDB reports it: md5- and the base64 hash
@type doc_ref() :: Sofa.Doc.t() | String.t()
A document, or just its id where no revision is involved
@type error() :: Sofa.Response.error() | :digest_mismatch | term()
Why a request failed, as the usual atoms, or the response for anything else
@type t() :: %Sofa.Attachment{ content_type: String.t(), data: binary() | term() | nil, digest: digest() | nil, encoded_length: non_neg_integer() | nil, encoding: String.t() | nil, length: non_neg_integer() | nil, name: String.t(), revpos: pos_integer() | nil }
Functions
Deletes an attachment, and returns the document with its new revision
and without the stub. A revision is required, from the document or as
rev:. batch: :ok defers the write as the docs describe.
@spec delete!(doc_ref(), Sofa.t(), String.t(), keyword()) :: Sofa.Doc.t()
Bang! version of delete/4. Returns the updated document or raises Sofa.Error.
The md5- digest CouchDB reports for content.
CouchDB computes it over the bytes it stores. For content types in its
compressible_types list, such as text/plain, those are the gzipped
bytes, so the digest of such an attachment does not match its plain
content. encoding tells the two apart.
iex> Sofa.Attachment.digest("My hovercraft is full of eels!")
"md5-uoLJeG8NfCWNzP/3AI2Ytw=="
Builds an attachment from one entry of a document's _attachments.
Inline data is base64 decoded. A stub keeps data as nil.
@spec get(Sofa.t(), doc_ref(), String.t(), keyword()) :: {:ok, t() | :not_modified} | {:error, error()}
Fetches an attachment's content with the metadata from the response
headers. The bytes are verified against the digest CouchDB sends as the
ETag, giving {:error, :digest_mismatch} if they differ, and an
attachment CouchDB stored gzipped is then decompressed, with encoding
and encoded_length recording that.
Options
rev:andif_none_match:as forhead/4range:afirst..lastbyte range, fetched with aRangeheader.lengthis then the size of the part, which is not verifiedinto:aReqsink, such asFile.stream!(path)or a function, for content too large to hold in memory.datais then whatever the sink returns, decompressed by Req on the way but not verified
Examples
{:ok, att} = Sofa.Attachment.get(db, doc, "recipe.txt")
att.data
#=> "1. Cook spaghetti\\n2. Cook meatballs\\n..."
Sofa.Attachment.get(db, doc, "video.mp4", into: File.stream!("/tmp/video.mp4"))
#=> {:ok, %Sofa.Attachment{data: %File.Stream{}, ...}}
Bang! version of get/4. Returns the attachment or raises Sofa.Error.
@spec head(Sofa.t(), doc_ref(), String.t(), keyword()) :: {:ok, t() | :not_modified} | {:error, error()}
Fetches an attachment's metadata from the headers of a HEAD request:
content type, size and the digest CouchDB uses as the ETag.
For an attachment CouchDB stored compressed, encoding is set and
encoded_length holds the size from the headers, which is the
compressed one as the docs warn. The plain length is then unknown.
Options
rev:a document revision, otherwise the current one is usedif_none_match:a digest.{:ok, :not_modified}is returned when it still matches
Examples
Sofa.Attachment.head(db, "SpaghettiWithMeatballs", "recipe.txt")
#=> {:ok, %Sofa.Attachment{name: "recipe.txt", content_type: "text/plain", ...}}
An attachment with content, ready to be attached to a document.
The content type comes from the name via MIME.from_path/1, falling back
to application/octet-stream, unless given as content_type:. The
digest is left to CouchDB, which computes it over the bytes it stores.
@spec put(doc_ref(), Sofa.t(), String.t(), content(), keyword()) :: {:ok, Sofa.Doc.t()} | {:error, error()}
Uploads content as an attachment in its own request, and returns the document with its new revision and the attachment's stub, ready for the next write. The document comes first so that uploads pipe.
The document's rev is sent as If-Match, which CouchDB requires for an
existing document. A document without one, or a bare id, is created by
the upload, as the docs describe. Content is a binary, {:file, path}
streamed from disk, or any enumerable of binaries sent as a chunked
upload. The returned stub has no digest, as CouchDB computes that over
the bytes it stores, which it may compress first.
Options
content_type:the MIME type, by default taken from the name viaMIME.from_path/1rev:a revision to use instead of the document's
Examples
doc
|> Sofa.Attachment.put!(db, "recipe.txt", File.read!("recipe.txt"))
|> Sofa.Attachment.put!(db, "video.mp4", {:file, "video.mp4"})
Bang! version of put/5. Returns the updated document or raises Sofa.Error.
The _attachments entry for an attachment: inline, with the content
base64 encoded, when it has content, otherwise a stub that tells CouchDB
to keep the attachment it already has. Keys CouchDB did not report are
left out.
Whether the content matches the digest. false when either is missing,
or when CouchDB stored the content compressed, as the digest then covers
the gzipped bytes. get/4 verifies those while it still has them.
iex> att = Sofa.Attachment.new("a.bin", "eels")
iex> %{att | digest: Sofa.Attachment.digest("eels")} |> Sofa.Attachment.verified?()
true
iex> %Sofa.Attachment{name: "a", content_type: "text/plain"} |> Sofa.Attachment.verified?()
false