# `Sofa.Attachment`
[🔗](https://github.com/skunkwerks/sofa/blob/0.5.1/lib/sofa/attachment.ex#L1)

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

# `content`

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

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

# `digest`

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

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

# `doc_ref`

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

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

# `error`

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

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

# `t`

```elixir
@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
}
```

# `delete`

```elixir
@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!`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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!`

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

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

# `head`

```elixir
@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`

```elixir
@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`

```elixir
@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!`

```elixir
@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`

```elixir
@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?`

```elixir
@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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
