Skip to content

Commit

Permalink
Refactor library API (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
thiagomajesk authored Jun 3, 2023
1 parent bd686a3 commit 27e8fd7
Show file tree
Hide file tree
Showing 29 changed files with 897 additions and 852 deletions.
112 changes: 52 additions & 60 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
# Dropkick

Dropkick is a highly experimental library that provides easy to use uploads for the Elixir/ Phoenix ecosystem.
Dropkick is a highly experimental library that provides easy to use uploads for the Elixir/ Phoenix ecosystem.
This is a opinionated library focused on developer ergonomics that you can use to provide file uploads in any Phoenix project.

Some inspiration was taken from other projects like [Capsule](https://github.com/elixir-capsule/capsule) and [Waffle](https://github.com/elixir-waffle/waffle) as well as Ruby's [Shrine](https://shrinerb.com/).

## Installation

```elixir
Expand All @@ -19,83 +17,77 @@ end

### Setup

- Add a map column to your database: `add(:avatar, :map)`
- Add a field to your schema: `field(:avatar, Dropkick.Attachment)`
- Add a map column to your database table: `add(:avatar, :map)`
- Add a `Dropkick.File` field to your ecto schema: `field(:avatar, Dropkick.File)`

### Basic uploader example
### Configuration

You can setup a very basic uploader like this:
Add the following configuration to your `config.exs`:

```elixir
defmodule FileUploader do
use Dropkick.Uploader

@impl true
def validate(_attachable, %{action: :store}), do: :ok
end
config :dropkick,
repo: MyAppRepo,
storage: Dropkick.Storage.Disk,
folder: "uploads"
```

<<<<<<< HEAD
### Uploader

Define an uplodader for your application:
=======
After that, you simply cast the type like you would normally do:
>>>>>>> master
```elixir
def changeset(user, attrs) do
user
|> cast(attrs, [:avatar])
|> validate_required([:avatar])
|> prepare_changes(&store_attachments(&1, [:avatar]))
end
defmodule MyApp.Uploader do
use Dropkick.Uploader

# You can add a little helper function to properly store the attachment
# and process everything only when the changeset is actually valid.
defp store_attachments(changeset, fields) do
Enum.reduce(fields, changeset, fn field, chset ->
if attachment = Ecto.Changeset.get_change(changeset, field) do
case FileUploader.store(attachment) do
{:ok, atch} -> Ecto.Changeset.put_change(chset, field, atch)
{:error, reason} -> Ecto.Changeset.add_error(chset, field, to_string(reason))
end
end
end)
end
# Defines where to store the user avatar through pattern matching
def storage_prefix({user, :avatar}), do: "avatars/#{user.id}"

# You can also implement a list of callbacks that allow you to
# customize what happens in your upload pipeline
# def process(file, _scope), do: {:ok, file}
# def before_store(file, _scope), do: {:ok, file}
# def after_store(file, _scope), do: {:ok, file}
# def before_delete(file, _scope), do: {:ok, file}
# def after_delete(file, _scope), do: {:ok, file}
end
```

### Async uploader

You can also do async uploads by doing some modifications in the upload workflow. First you want to have an endpoint that saves (or caches) files when a user interacts with a file uploader on the frontend. When an upload arrives at this endpoint you want to call the function `FileUploader.cache(upload)` to save the file into a temporary folder (this folder should ideally be cleaned from time to time).
### Save the files

```elixir
defmodule FileUploader do
use Dropkick.Uploader
import Dropkick.Context

# Skip validation when storing the file
def validate(_attachable, %{action: :store}), do: :ok

# Only validates the file when caching, since we are doing async uploads
def validate(%{filename: filename}, %{action: :cache}) do
extension = Path.extname(filename)

case Enum.member?(~w(.jpg .jpeg .gif .png), extension) do
true -> :ok
false -> {:error, "invalid file type"}
end
end

# You can change how the files will be saved
def storage_prefix(_attachable, scope) do
%{year: year, month: month, day: day} = DateTime.utc_now()
# The current action is automatically added to the scope, but you can
# call the cache and store functions with custom scopes to customize this even further.
"#{to_string(scope.action)}/#{year}/#{month}/#{day}"
end
def create_user(user, attrs) do
user
|> User.changeset(attrs)
|> insert_with_files(MyApp.Uploader)
end
```

If you are using forms to submit the final file, you'll likely want to return the cached file path or an identifier to the frontend so you can retrieve the file in the next post (you can save this identifier into a hidden field for instance). And then, after you finish doing your schema validations you can simply call `FileUploader.store` to store the file into its final location.

> If you don't want to expose the file path, you can use the function `Dropkick.Security.sign(attachment)` to generate a token that you can send to clients. This might be desirable if you are uploading files to disk as it prevents a malicius user from tampering with the final file location.
def update_user(user, attrs) do
user
|> User.changeset(attrs)
|> update_with_files(MyApp.Uploader)
end
```

## Missing bits

<<<<<<< HEAD
- Add integration for file transformations
- Add integration with [Briefly](https://hexdocs.pm/briefly) to make transformation/ cleanup of temporary files easier.
- Support other types of storages (S3, Azure, etc)
- Add strategy to allow cleaning up old files after update
- Improve documentation and examples for modules and functions
- Add examples of using libraries for processing files:
- [`image`](https://hexdocs.pm/image)
- [`ex_optimizer`](https://hexdocs.pm/ex_optimizer)
- [`mogrify`](https://hexdocs.pm/mogrify)
=======
- Implement more image transformations
- Add video transformations
- Add support to S3 storage
>>>>>>> master
4 changes: 0 additions & 4 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
import Config

config :dropkick,
storage: Dropkick.Storage.Memory,
secret_key_base: Base.encode64(String.duplicate("x", 12))

import_config "#{Mix.env()}.exs"
5 changes: 4 additions & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import Config

config :dropkick, storage: Dropkick.Storage.Disk
config :dropkick,
repo: TestRepo,
storage: Dropkick.Storage.Disk,
folder: "uploads"
105 changes: 1 addition & 104 deletions lib/dropkick.ex
Original file line number Diff line number Diff line change
@@ -1,106 +1,3 @@
defmodule Dropkick do
@moduledoc """
This module provides functions that you can use to interact directly with uploads and attachments.
"""

alias Dropkick.Attachment

@doc """
Creates a version of the attachment with some transformation.
Transformations validated against an attachment `content_type`.
The current transformations supported are:
## `image/*`
Image transformations uses the [`image`](https://hexdocs.pm/image) library behind the scenes
- `{:thumbnail, size, opts}`: Generates an image thumbnail, receives the same options
as [`Image.thumbnail/3`](https://hexdocs.pm/image/Image.html#thumbnail/3)
"""
def transform(%Attachment{} = atch, transforms) do
atch
|> transform_stream(transforms)
|> Stream.filter(&match?({:ok, _}, &1))
|> Enum.reduce(atch, fn {:ok, version}, atch ->
Map.update!(atch, :versions, fn versions -> [version | versions] end)
end)
end

@doc """
Extracts context from the attachment.
"""
def contextualize(%Attachment{} = atch) do
key = Dropkick.Attachable.key(atch)

%{
extension: Path.extname(key),
directory: Path.dirname(key),
filename: Path.basename(key)
}
end

@doc """
Extracts metadata from the attachment.
"""
def extract_metadata(%Attachment{content_type: "image/" <> _} = atch) do
# If our attachment is an image, we try to extract additional information.
# Depending on the complexity we should probably move this into a 'Metadata' module in the future.
case Dropkick.Attachable.content(atch) do
{:ok, content} ->
{mimetype, width, height, variant} = ExImageInfo.info(content)

%{
mimetype: mimetype,
dimension: "#{width}x#{height}",
variant: variant
}

_ ->
%{}
end
end

# If we don't yet support extracting metadata from the content type we do nothing.
# In the future this could be expanded to other formats as long as we have a proper lib in the ecosystem do do that.
def extract_metadata(%Attachment{} = atch), do: atch

@doc """
Calls the underlyning storage's `put` function.
Check the module `Dropkick.Storage` for documentation about the available options.
"""
def put(attachable, opts \\ []),
do: Dropkick.Storage.current().put(attachable, opts)

@doc """
Calls the underlyning storage's `read` function.
Check the module `Dropkick.Storage` for documentation about the available options.
"""
def read(attachable, opts \\ []),
do: Dropkick.Storage.current().read(attachable, opts)

@doc """
Calls the underlyning storage's `copy` function.
Check the module `Dropkick.Storage` for documentation about the available options.
"""
def copy(attachable, dest, opts \\ []),
do: Dropkick.Storage.current().copy(attachable, dest, opts)

@doc """
Calls the underlyning storage's `delete` function.
Check the module `Dropkick.Storage` for documentation about the available options.
"""
def delete(attachable, opts \\ []),
do: Dropkick.Storage.current().delete(attachable, opts)

defp transform_stream(atch, transforms) do
Task.Supervisor.async_stream_nolink(Dropkick.TransformTaskSupervisor, transforms, fn
{:thumbnail, size, params} ->
with {:ok, transform} <- Dropkick.Transform.thumbnail(atch, size, params),
{:ok, version} <- put(transform, folder: Path.dirname(transform.key)) do
version
end

transform ->
raise "Not a valid transform param #{inspect(transform)}"
end)
end
@moduledoc File.read!("README.md")
end
102 changes: 0 additions & 102 deletions lib/dropkick/attachable.ex

This file was deleted.

Loading

0 comments on commit 27e8fd7

Please sign in to comment.