# `SiteMaps.Adapter`
[🔗](https://github.com/marcosgz/site_maps_ex/blob/v0.1.0/lib/site_maps/adapter.ex#L1)

Storage behaviour used to publish, read, list and delete sitemap files.

An adapter is configured as a `{module, opts}` tuple. Keys are always relative
to the adapter root (a directory, a bucket prefix, ...), use `/` as separator
and never start with `/`.

Built-in adapters:

  * `SiteMaps.Adapters.FileSystem` - local disk and network mounts (NFS, EFS, SMB)
  * `SiteMaps.Adapters.S3` - AWS S3 and S3-compatible stores (MinIO, Spaces, Wasabi, Ceph)
  * `SiteMaps.Adapters.R2` - Cloudflare R2
  * `SiteMaps.Adapters.Memory` - in-memory storage for tests
  * `SiteMaps.Adapters.Noop` - discards every write (dry runs)

`read/2` returns the raw stored bytes, so a `.gz` key comes back gzipped.
A missing key is always reported as `{:error, :not_found}` and deleting a
missing key is `:ok`. `last_modified` is kept with second precision by every
adapter.

Every adapter rejects keys failing `validate_key/1` with `{:error, :invalid_key}`.

# `key`

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

# `opts`

```elixir
@type opts() :: keyword()
```

# `read_meta`

```elixir
@type read_meta() :: %{
  content_type: String.t() | nil,
  last_modified: DateTime.t() | nil
}
```

# `source`

```elixir
@type source() :: {:file, Path.t()} | iodata()
```

# `t`

```elixir
@type t() :: {module(), opts()}
```

# `write_meta`

```elixir
@type write_meta() :: %{
  optional(:content_type) =&gt; String.t(),
  optional(:cache_control) =&gt; String.t() | nil,
  optional(:last_modified) =&gt; DateTime.t() | nil
}
```

# `delete`

```elixir
@callback delete(opts(), key()) :: :ok | {:error, term()}
```

# `list`

```elixir
@callback list(opts(), prefix :: String.t()) :: {:ok, [key()]} | {:error, term()}
```

# `read`

```elixir
@callback read(opts(), key()) ::
  {:ok, binary(), read_meta()} | {:error, :not_found | term()}
```

# `write`

```elixir
@callback write(opts(), key(), source(), write_meta()) :: :ok | {:error, term()}
```

# `content_type`

```elixir
@spec content_type(key()) :: String.t()
```

Content type derived from the key extension.

    iex> SiteMaps.Adapter.content_type("posts/sitemap1.xml.gz")
    "application/gzip"
    iex> SiteMaps.Adapter.content_type("sitemap.xml")
    "application/xml"

# `delete`

```elixir
@spec delete(t(), key()) :: :ok | {:error, term()}
```

Deletes `key`. Deleting a missing key is `:ok`.

# `list`

```elixir
@spec list(t(), String.t()) :: {:ok, [key()]} | {:error, term()}
```

Lists the keys starting with `prefix`, sorted.

# `read`

```elixir
@spec read(t(), key()) :: {:ok, binary(), read_meta()} | {:error, :not_found | term()}
```

Reads the raw bytes stored under `key`.

# `validate_key`

```elixir
@spec validate_key(term()) :: :ok | {:error, :invalid_key}
```

Checks that `key` is a relative path without empty, `.` or `..` segments,
segments named like a temporary file (`.<name>.tmp`), whitespace, control
characters or the URL-sensitive characters `?`, `#` and `%`.

    iex> SiteMaps.Adapter.validate_key("posts/sitemap1.xml")
    :ok
    iex> SiteMaps.Adapter.validate_key("/sitemap.xml")
    {:error, :invalid_key}
    iex> SiteMaps.Adapter.validate_key("sitemap.xml?v=1")
    {:error, :invalid_key}

# `write`

```elixir
@spec write(t(), key(), source(), write_meta()) :: :ok | {:error, term()}
```

Stores `source` under `key`.

---

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