# `Fil.Adapter.S3`
[🔗](https://github.com/pehbehbeh/fil/blob/v0.1.0/lib/fil/adapter/s3.ex#L1)

Amazon S3 and services that implement its API, such as [MinIO](https://github.com/minio/minio),
[Adobe S3Mock](https://github.com/adobe/S3Mock), [Cloudflare R2](https://developers.cloudflare.com/r2/),
[Backblaze B2](https://www.backblaze.com/cloud-storage), [Tigris](https://www.tigrisdata.com) and
[Ceph](https://ceph.io).

    disk =
      Fil.disk(
        adapter: Fil.Adapter.S3,
        bucket: "invoices",
        region: "eu-central-1",
        access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"),
        secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY")
      )

Requests are sent and signed (SigV4) by [Req](https://req.hexdocs.pm), configured with `:req_options`. Listings are
parsed with OTP's `:xmerl_sax_parser`.

## Checksums

S3 stores a checksum with an object when the write sends one. With `checksum: :sha256` (or `:sha1`, `:crc32`),
`Fil.write/4` sends the checksum of the content, S3 rejects the upload if what it received doesn't match, and
`Fil.stat/3` with the same `:checksum` option returns the stored value. `Fil.read/3` with `verify_checksum: true` asks
S3 for the stored checksum and compares it with the downloaded content. Objects stored with another algorithm, or with
none, are read without a check.

## Options

* `:bucket` (`t:String.t/0`) - Required. The bucket all paths are stored in.

* `:region` (`t:String.t/0`) - The bucket's region. The default value is `"us-east-1"`.

* `:root` (`t:String.t/0`) - The key prefix every path is resolved against, e.g. `"uploads"`. Paths on the disk stay relative to
  it, and listings strip it again. Defaults to the bucket root. The default value is `"."`.

* `:access_key_id` (`t:String.t/0`) - The access key. `Fil` doesn't look up credentials itself (no environment variables, no instance metadata),
  so where they come from is up to the application. Leave out all credentials to access a public bucket
  without signing.

* `:secret_access_key` (`t:String.t/0`) - The secret matching `:access_key_id`.

* `:session_token` (`t:String.t/0`) - The session token, for temporary STS credentials.

* `:endpoint` (`t:String.t/0`) - A base URL for S3-compatible services, e.g. `"http://localhost:8333"`. Setting it turns `:path_style` on.

* `:path_style` (`t:boolean/0`) - Put the bucket in the path (`https://host/bucket/key`) instead of the hostname
  (`https://bucket.host/key`). Defaults to `true` when `:endpoint` is set, `false` otherwise.

* `:req_options` (`t:keyword/0`) - Options for every [Req](https://req.hexdocs.pm) request the disk makes, such as `:receive_timeout`,
  `:connect_options` or a shared `:finch` pool. The adapter always sets `:method`, `:url`, `:headers` and
  `:body`, plus `retry: false` (retrying is up to the caller) and `raw: true` (no decompression and no body
  decoding, so a file reads back exactly as it was written). The default value is `[]`.

## Operations

| `Fil` | S3 |
| --- | --- |
| `read/3` | GetObject (`x-amz-checksum-mode: ENABLED` for `verify_checksum: true`) |
| `write/4` | PutObject (`If-None-Match: *` for `if_exists: :error`, `x-amz-checksum-*` for `checksum:`) |
| `rm/3` | DeleteObject, which S3 already treats as idempotent (a `404` for a missing bucket is still an error) |
| `stat/3` | HeadObject (`x-amz-checksum-mode: ENABLED` for `checksum:`), then a prefix probe so `dir?/1` works |
| `ls/3` | ListObjectsV2, `delimiter=/` unless recursive, paginated internally |
| `cp/4` | CopyObject |
| `rename/4` | CopyObject, then DeleteObject |
| `rm_rf/3` | ListObjectsV2, then one DeleteObject per key |
| `url/2` | the object URL, without a signature (works for public objects only) |
| `signed_url/3` | a presigned GET or PUT URL |

## Errors

`:reason` is the error code from the response body (`"NoSuchKey"`), or `{:http_status, status}` when there's none.
Failures before a response keep Req's reason (`:timeout`, an exception). The error code decides first, whatever the
status, because S3-compatible servers don't all send the same one.

| S3 response | `Fil` error |
| --- | --- |
| `NoSuchKey`, or `404` | `Fil.NotFoundError` |
| `NoSuchBucket` | `Fil.ConfigurationError` |
| a `400` for a copy whose source doesn't exist (checked with HeadObject) | `Fil.NotFoundError` |
| `AccessDenied`, or `403` | `Fil.AccessDeniedError` |
| `EntityTooLarge`, `KeyTooLongError` | `Fil.InvalidRequestError` |
| `PreconditionFailed`, `ConditionalRequestConflict` | `Fil.AlreadyExistsError` |
| `412`, or a `409` without a code | `Fil.AlreadyExistsError` |
| `BadDigest` | `Fil.ChecksumMismatchError` |
| a body that doesn't match its stored checksum | `Fil.ChecksumMismatchError`, `reason: :checksum_mismatch` |
| a signed URL on a disk without credentials | `Fil.UnsupportedError`, `reason: :missing_credentials` |
| `301`, or a `400` that gives another region | `Fil.ConfigurationError`, `reason: {:wrong_region, region}` |
| `SlowDown`, `OperationAborted`, `InternalError`, `ServiceUnavailable` | `Fil.UnavailableError` |
| `429`, `5xx` | `Fil.UnavailableError` |
| timeouts, failed connections, an unreadable listing | `Fil.UnavailableError` |
| anything else, including an error inside the `200` of a CopyObject | `Fil.UnknownError` |

# `t`

```elixir
@type t() :: %Fil.Adapter.S3{
  access_key_id: term(),
  bucket: term(),
  endpoint: term(),
  path_style: term(),
  prefix: term(),
  region: term(),
  req_options: term(),
  secret_access_key: term(),
  session_token: term()
}
```

