Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ This automatically adds:

- `has_one :cover_image` / `has_many :documents` relationships to load attachments
- A `cover_image_url` calculation for each `has_one_attached`
- A `url` calculation on each attachment record

## Usage

Expand Down Expand Up @@ -145,6 +146,11 @@ post.cover_image_url
post = Ash.load!(post, documents: :blob)
Enum.map(post.documents, & &1.blob.filename)
#=> ["report.pdf", "notes.txt"]

# Load URLs via the attachment's url calculation
post = Ash.load!(post, documents: [:url])
Enum.map(post.documents, & &1.url)
#=> ["/storage/a81bf21e2442...", "/storage/f9c3e71d8810..."]
```

### Detaching and purging
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ defmodule AshStorage.AttachmentResource.Transformers.SetupAttachment do
dsl_state
|> add_attributes(belongs_to_resources)
|> add_relationships(blob_resource, belongs_to_resources)
|> add_calculations(belongs_to_resources)
|> add_actions(belongs_to_resources)
end

Expand Down Expand Up @@ -80,6 +81,25 @@ defmodule AshStorage.AttachmentResource.Transformers.SetupAttachment do

defp add_relationships({:error, error}, _, _), do: {:error, error}

defp add_calculations({:ok, dsl_state}, belongs_to_resources) do
parent_resources =
Enum.map(belongs_to_resources, fn %{name: name, resource: resource} ->
{name, resource}
end)

Ash.Resource.Builder.add_calculation(
dsl_state,
:url,
:string,
{AshStorage.Calculations.Url, parent_resources: parent_resources},
public?: true,
filterable?: false,
sortable?: false
)
end

defp add_calculations({:error, error}, _), do: {:error, error}

# sobelow_skip ["DOS.BinToAtom"]
defp add_actions({:ok, dsl_state}, belongs_to_resources) do
accept =
Expand Down
5 changes: 0 additions & 5 deletions lib/ash_storage/calculations/attachment_url.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,6 @@ defmodule AshStorage.Calculations.AttachmentUrl do
@moduledoc false
use Ash.Resource.Calculation

@impl true
def init(opts) do
{:ok, opts}
end

@impl true
def strict_loads?, do: false

Expand Down
5 changes: 0 additions & 5 deletions lib/ash_storage/calculations/attachment_urls.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,6 @@ defmodule AshStorage.Calculations.AttachmentUrls do
@moduledoc false
use Ash.Resource.Calculation

@impl true
def init(opts) do
{:ok, opts}
end

@impl true
def strict_loads?, do: false

Expand Down
62 changes: 62 additions & 0 deletions lib/ash_storage/calculations/url.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
defmodule AshStorage.Calculations.Url do
@moduledoc false
use Ash.Resource.Calculation

@impl true
def strict_loads?, do: false

# sobelow_skip ["DOS.BinToAtom"]
@impl true
def load(_query, opts, _context) do
case opts[:parent_resources] do
[] ->
[:name, :record_type, :blob]

parents ->
parent_fields = Enum.map(parents, fn {name, _} -> :"#{name}_id" end)
[:name, :blob | parent_fields]
end
end

# sobelow_skip ["DOS.BinToAtom"]
@impl true
def calculate(records, opts, context) do
parent_resources = opts[:parent_resources]

{:ok,
Enum.map(records, fn attachment ->
with {:ok, resource} <- resolve_parent_resource(attachment, parent_resources),
attachment_name = String.to_existing_atom(attachment.name),
{:ok, attachment_def} <- AshStorage.Info.attachment(resource, attachment_name),
{:ok, {service_mod, service_opts}} <-
AshStorage.Info.service_for_attachment(resource, attachment_def) do
ctx =
AshStorage.Service.Context.new(service_opts,
resource: resource,
attachment: attachment_def,
actor: Map.get(context, :actor),
tenant: Map.get(context, :tenant)
)

service_mod.url(attachment.blob.key, ctx)
else
_ -> nil
end
end)}
end

# sobelow_skip ["DOS.BinToAtom"]
defp resolve_parent_resource(attachment, []) do
{:ok, String.to_existing_atom(attachment.record_type)}
end

# sobelow_skip ["DOS.BinToAtom"]
defp resolve_parent_resource(attachment, parent_resources) do
case Enum.find_value(parent_resources, fn {name, resource} ->
if Map.get(attachment, :"#{name}_id") != nil, do: resource
end) do
nil -> :error
resource -> {:ok, resource}
end
end
end
5 changes: 0 additions & 5 deletions lib/ash_storage/calculations/variant_url.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,6 @@ defmodule AshStorage.Calculations.VariantUrl do
@moduledoc false
use Ash.Resource.Calculation

@impl true
def init(opts) do
{:ok, opts}
end

@impl true
def strict_loads?, do: false

Expand Down
5 changes: 0 additions & 5 deletions lib/ash_storage/calculations/variant_urls.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,6 @@ defmodule AshStorage.Calculations.VariantUrls do
@moduledoc false
use Ash.Resource.Calculation

@impl true
def init(opts) do
{:ok, opts}
end

@impl true
def strict_loads?, do: false

Expand Down
8 changes: 1 addition & 7 deletions lib/ash_storage/transformers/setup_storage.ex
Original file line number Diff line number Diff line change
Expand Up @@ -77,17 +77,11 @@ defmodule AshStorage.Transformers.SetupStorage do
opts =
[
destination_attribute: destination_attribute,
validate_destination_attribute?: not is_nil(parent_rel),
filters: [name_filter],
public?: true
]

opts =
if is_nil(parent_rel) do
Keyword.put(opts, :validate_destination_attribute?, false)
else
opts
end

opts =
if attachment_def.sort do
Keyword.put(opts, :sort, attachment_def.sort)
Expand Down
82 changes: 82 additions & 0 deletions test/ash_storage/url_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,88 @@ defmodule AshStorage.UrlTest do
end
end

describe "attachment URL calculation" do
test "returns URL for has_one attachment" do
post = create_post!()

{:ok, %{blob: blob}} =
AshStorage.Operations.attach(post, :cover_image, "data",
filename: "photo.jpg",
content_type: "image/jpeg"
)

post = Ash.load!(post, cover_image: [:url])
assert post.cover_image.url == "http://test.local/storage/#{blob.key}"
end

test "returns URLs for has_many attachments" do
post = create_post!()

{:ok, %{blob: blob1}} =
AshStorage.Operations.attach(post, :documents, "doc1",
filename: "a.txt",
content_type: "text/plain"
)

{:ok, %{blob: blob2}} =
AshStorage.Operations.attach(post, :documents, "doc2",
filename: "b.txt",
content_type: "text/plain"
)

post = Ash.load!(post, documents: [:url])

urls =
post.documents
|> Enum.map(& &1.url)
|> Enum.sort()

expected =
[blob1.key, blob2.key]
|> Enum.map(&"http://test.local/storage/#{&1}")
|> Enum.sort()

assert urls == expected
end
end

describe "attachment URL calculation with variants" do
defp create_variant_post! do
AshStorage.Test.VariantPost
|> Ash.Changeset.for_create(:create, %{title: "test"})
|> Ash.create!()
end

test "returns source blob URL when variants exist" do
post = create_variant_post!()

{:ok, %{blob: blob}} =
AshStorage.Operations.attach(post, :document, "hello",
filename: "test.txt",
content_type: "text/plain"
)

post = Ash.load!(post, document: [:url])
assert post.document.url == "http://test.local/storage/#{blob.key}"
end

test "source blob URL is distinct from variant URLs" do
post = create_variant_post!()

{:ok, _} =
AshStorage.Operations.attach(post, :document, "hello",
filename: "test.txt",
content_type: "text/plain"
)

post = Ash.load!(post, [:document_eager_uppercase_url, document: [:url]])

# Attachment url is the source blob URL
# Variant url is a different blob's URL
assert post.document.url != post.document_eager_uppercase_url
end
end

describe "Disk signed URLs" do
test "generates plain URL without secret" do
ctx =
Expand Down
Loading