Sofa.Attachment (sofa v0.5.1)

Copy Markdown View Source

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 under data
  • 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

t()

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

content()

@type content() :: binary() | {:file, Path.t()} | Enumerable.t()

Content to upload: in memory, from a file, or any enumerable of binaries

digest()

@type digest() :: String.t()

A content digest as CouchDB reports it: md5- and the base64 hash

doc_ref()

@type doc_ref() :: Sofa.Doc.t() | String.t()

A document, or just its id where no revision is involved

error()

@type error() :: Sofa.Response.error() | :digest_mismatch | term()

Why a request failed, as the usual atoms, or the response for anything else

t()

@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

delete(doc, sofa, name, opts \\ [])

@spec delete(doc_ref(), Sofa.t(), String.t(), keyword()) ::
  {:ok, Sofa.Doc.t()} | {:error, error()}

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.

delete!(doc, sofa, name, opts \\ [])

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

digest(data)

@spec digest(binary()) :: digest()

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=="

from_stub(name, stub)

@spec from_stub(String.t(), map()) :: t()

Builds an attachment from one entry of a document's _attachments.

Inline data is base64 decoded. A stub keeps data as nil.

get(sofa, doc, name, opts \\ [])

@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: and if_none_match: as for head/4
  • range: a first..last byte range, fetched with a Range header. length is then the size of the part, which is not verified
  • into: a Req sink, such as File.stream!(path) or a function, for content too large to hold in memory. data is 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{}, ...}}

get!(sofa, doc, name, opts \\ [])

@spec get!(Sofa.t(), doc_ref(), String.t(), keyword()) :: t()

Bang! version of get/4. Returns the attachment or raises Sofa.Error.

head(sofa, doc, name, opts \\ [])

@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 used
  • if_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", ...}}

new(name, data, opts \\ [])

@spec new(String.t(), binary(), keyword()) :: t()

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.

put(doc, sofa, name, content, opts \\ [])

@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 via MIME.from_path/1
  • rev: 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"})

put!(doc, sofa, name, content, opts \\ [])

@spec put!(doc_ref(), Sofa.t(), String.t(), content(), keyword()) :: Sofa.Doc.t()

Bang! version of put/5. Returns the updated document or raises Sofa.Error.

to_stub(att)

@spec to_stub(t()) :: map()

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.

verified?(attachment)

@spec verified?(t()) :: boolean()

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