Skip to content

Commit a0076b4

Browse files
authored
Add uploader (#9)
1 parent 081866f commit a0076b4

File tree

5 files changed

+132
-3
lines changed

5 files changed

+132
-3
lines changed

README.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Then to access the file:
3838

3939
## concepts
4040

41-
There are three main concepts in capsule: storage, upload, and locator
41+
There are four main concepts in capsule: storage, upload, and locator, and uploader.
4242

4343
*Note: As of version 0.6 Capsule all built-in storages and uploads except for Locator have been moved to [elixir-capsule/supplement](https://github.com/elixir-capsule/supplement).*
4444

@@ -76,6 +76,32 @@ Note: you'll still need to take care of cleaning up the old file:
7676

7777
`YourStorage.delete(old_file_data.id)`
7878

79+
### uploader
80+
81+
This helper was added in order to support DRYing up storage access. In most apps, there are certain types of assets that will be uploaded and handled in a similar, if not the same way, if only when it comes to where they are stored. You can `use` the uploader to codify the handling for specific types of assets.
82+
83+
```
84+
defmodule AvatarUploader do
85+
use Capsule.Uploader, storages: [cache: Disk, store: S3]
86+
87+
def storage_options(upload, :cache, opts) do
88+
Keyword.put(opts, :prefix, "cache/#{Date.utc_today()}")
89+
end
90+
91+
def storage_options(upload, :store, opts) do
92+
opts
93+
|> Keyword.put(:prefix, "users/#{opts[:user_id]}/avatar")
94+
|> Keyword.drop(:user_id)
95+
end
96+
97+
def build_metadata(upload, :store, _), do: [uploaded_at: DateTime.utc_now()]
98+
end
99+
```
100+
101+
Then you can get the files where they need to be without constructing all the options everywhere they might be uploaded: `AvatarUploader.store(upload, :store, user_id: 1)`
102+
103+
Note: as this example demonstrates, the function can receive arbitrary data and use it to customize how it builds the storage options before they are passed on.
104+
79105
## integrations
80106

81107
* Ecto via [CapsuleEcto](https://github.com/elixir-capsule/capsule_ecto)

lib/capsule/uploader.ex

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
defmodule Capsule.Uploader do
2+
alias Capsule.Locator
3+
4+
@type storage :: atom()
5+
@type option :: {atom(), any()}
6+
7+
@callback store(any(), storage, [option]) :: {:ok, Locator.t()} | {:error, any()}
8+
@callback build_options(any(), storage, [option]) :: [option]
9+
@callback build_metadata(Locator.t(), storage, [option]) :: Keyword.t() | map()
10+
11+
defmacro __using__(opts) do
12+
quote bind_quoted: [opts: opts] do
13+
@behaviour Capsule.Uploader
14+
15+
@storages Keyword.fetch!(opts, :storages)
16+
17+
@impl Capsule.Uploader
18+
def store(upload, storage_key, opts \\ []) do
19+
storage = fetch_storage!(upload, storage_key)
20+
21+
upload
22+
|> storage.put(build_options(upload, storage_key, opts))
23+
|> case do
24+
{:ok, id} ->
25+
locator = Capsule.add_metadata(%Locator{id: id, storage: storage}, build_metadata(upload, storage_key, opts))
26+
27+
{:ok, locator}
28+
error_tuple ->
29+
30+
error_tuple
31+
end
32+
end
33+
34+
@impl Capsule.Uploader
35+
def build_metadata(_, _, _), do: []
36+
37+
@impl Capsule.Uploader
38+
def build_options(_, _, instance_opts), do: instance_opts
39+
40+
defp fetch_storage!(upload, storage) do
41+
@storages
42+
|> case do
43+
{m, f, a} -> apply(m, f, [upload | a])
44+
storages when is_list(storages) -> storages
45+
end
46+
|> Keyword.fetch(storage)
47+
|> case do
48+
{:ok, storage} -> storage
49+
_ -> raise "#{storage} not found in #{__MODULE__} storages. Available: #{inspect(Keyword.keys(@storages))}"
50+
end
51+
end
52+
53+
defoverridable build_options: 3, build_metadata: 3
54+
end
55+
end
56+
end

test/capsule/locator_test.exs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ defmodule Capsule.LocatorTest do
1010
end
1111
end
1212

13-
1413
describe "new/1 with map with required string keys" do
1514
test "returns struct" do
1615
assert {:ok, %Locator{}} = Locator.new(%{"id" => "fake", "storage" => "Fake"})

test/capsule/uploader_test.exs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
defmodule Capsule.UploaderTest do
2+
use ExUnit.Case
3+
doctest Capsule
4+
5+
alias Capsule.{Locator, Uploader}
6+
7+
defmodule BasicUploader do
8+
use Uploader, storages: [temp: Capsule.Storages.Mock, perm: Capsule.Storages.Mock]
9+
end
10+
11+
defmodule DynamicUploader do
12+
use Uploader, storages: {__MODULE__, :get_storages, []}
13+
14+
def get_storages(_), do: [temp: Capsule.Storages.Mock]
15+
end
16+
17+
describe "store/2 with basic uploader and valid storage key" do
18+
setup do
19+
%{result: BasicUploader.store(%Locator{id: "fake"}, :temp)}
20+
end
21+
22+
test "succeeds", %{result: result} do
23+
assert {:ok, _} = result
24+
end
25+
26+
test "returns locator", %{result: result} do
27+
assert {_, %Locator{}} = result
28+
end
29+
end
30+
31+
describe "store/2 with basic uploader invalid storage key" do
32+
test "raises" do
33+
assert_raise(RuntimeError, fn ->
34+
BasicUploader.store(%Locator{id: "fake"}, :wrong)
35+
end)
36+
end
37+
end
38+
39+
describe "store/2 with dynamic uploader and valid storage key" do
40+
setup do
41+
%{result: BasicUploader.store(%Locator{id: "fake"}, :temp)}
42+
end
43+
44+
test "succeeds", %{result: result} do
45+
assert {:ok, _} = result
46+
end
47+
end
48+
end

test/support/mock_storage.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ defmodule Capsule.Storages.Mock do
55

66
@impl Storage
77
def put(_id, opts \\ []) do
8-
{:ok, opts[:id]}
8+
{:ok, Keyword.get(opts, :id, to_string(:erlang.ref_to_list(:erlang.make_ref())))}
99
end
1010

1111
@impl Storage

0 commit comments

Comments
 (0)