Skip to content

Commit 7c00dd5

Browse files
authored
improvement: support extra blob attrs from service upload (#9)
* improvement: support `{:ok, extra_blob_attrs}` return from service upload The Service.upload callback already declared `{:ok, map()}` as a valid return type, but none of the callers honoured it — they only matched on `:ok`. This meant wrapping services had no way to pass per-file metadata back into the blob record at creation time. All three upload sites (HandleFileArgument, Attach, VariantGenerator) now normalise the upload result through `normalize_upload/1` and merge any returned map into the blob attributes before `Ash.create`. A service that returns `:ok` still works unchanged; one that returns `{:ok, %{metadata: …}}` gets those entries merged into the blob. This is the minimal hook needed for wrapping services (e.g. encryption) to annotate blobs without requiring changes to the blob resource schema or the storage DSL. * fix: ensure SetupBlob runs before SetRelationshipSource The SetupBlob transformer adds relationships (source_blob, variants) but was not guaranteed to run before SetRelationshipSource, leaving their `source` field as nil. This caused AshPostgres to crash with `nil.persisted(:otp_app)` when resolving foreign key constraints during bulk updates (e.g. update_metadata). * test: add tests for {:ok, extra_blob_attrs} service upload return Covers all three code paths that handle the extra attrs merge: Operations.attach, AttachFile change (create + update), and VariantGenerator (eager variants).
1 parent bc51f5c commit 7c00dd5

9 files changed

Lines changed: 227 additions & 20 deletions

File tree

lib/ash_storage/blob_resource/transformers/setup_blob.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ defmodule AshStorage.BlobResource.Transformers.SetupBlob do
44

55
@before_transformers [
66
Ash.Resource.Transformers.DefaultAccept,
7-
Ash.Resource.Transformers.SetTypes
7+
Ash.Resource.Transformers.SetTypes,
8+
Ash.Resource.Transformers.SetRelationshipSource
89
]
910

1011
def before?(transformer) when transformer in @before_transformers, do: true

lib/ash_storage/changes/attach.ex

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -253,11 +253,10 @@ defmodule AshStorage.Changes.Attach do
253253
checksum = :crypto.hash(:md5, data) |> Base.encode64()
254254
byte_size = byte_size(data)
255255

256-
with :ok <- service_mod.upload(key, data, ctx) do
256+
with {:ok, extra_blob_attrs} <- normalize_upload(service_mod.upload(key, data, ctx)) do
257257
blob_resource = Info.storage_blob_resource!(resource)
258258

259-
Ash.create(
260-
blob_resource,
259+
blob_attrs =
261260
%{
262261
key: key,
263262
filename: filename,
@@ -267,12 +266,17 @@ defmodule AshStorage.Changes.Attach do
267266
service_name: service_mod,
268267
service_opts: persistable_service_opts(service_mod, ctx.service_opts),
269268
metadata: metadata
270-
},
271-
Keyword.merge(context_opts, action: :create)
272-
)
269+
}
270+
|> Map.merge(extra_blob_attrs)
271+
272+
Ash.create(blob_resource, blob_attrs, Keyword.merge(context_opts, action: :create))
273273
end
274274
end
275275

276+
defp normalize_upload(:ok), do: {:ok, %{}}
277+
defp normalize_upload({:ok, attrs}) when is_map(attrs), do: {:ok, attrs}
278+
defp normalize_upload({:error, _} = error), do: error
279+
276280
# -- IO helpers --
277281

278282
defp read_io(%Ash.Type.File{} = file) do

lib/ash_storage/changes/handle_file_argument.ex

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -294,11 +294,10 @@ defmodule AshStorage.Changes.HandleFileArgument do
294294
checksum = :crypto.hash(:md5, data) |> Base.encode64()
295295
byte_size = byte_size(data)
296296

297-
with :ok <- service_mod.upload(key, data, ctx) do
297+
with {:ok, extra_blob_attrs} <- normalize_upload(service_mod.upload(key, data, ctx)) do
298298
blob_resource = Info.storage_blob_resource!(resource)
299299

300-
Ash.create(
301-
blob_resource,
300+
blob_attrs =
302301
%{
303302
key: key,
304303
filename: filename,
@@ -308,12 +307,17 @@ defmodule AshStorage.Changes.HandleFileArgument do
308307
service_name: service_mod,
309308
service_opts: persistable_service_opts(service_mod, ctx.service_opts),
310309
metadata: %{}
311-
},
312-
action: :create
313-
)
310+
}
311+
|> Map.merge(extra_blob_attrs)
312+
313+
Ash.create(blob_resource, blob_attrs, action: :create)
314314
end
315315
end
316316

317+
defp normalize_upload(:ok), do: {:ok, %{}}
318+
defp normalize_upload({:ok, attrs}) when is_map(attrs), do: {:ok, attrs}
319+
defp normalize_upload({:error, _} = error), do: error
320+
317321
defp read_io(%Ash.Type.File{} = file) do
318322
{:ok, device} = Ash.Type.File.open(file, [:read, :binary])
319323
data = IO.binread(device, :eof)

lib/ash_storage/service.ex

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,13 @@ defmodule AshStorage.Service do
3232

3333
@doc """
3434
Upload a file to the storage service.
35+
36+
May return `:ok` or `{:ok, extra_blob_attrs}`. When a map is returned, its entries
37+
are merged into the blob record on creation. This allows wrapping services (e.g.
38+
encryption) to store per-file metadata such as encryption keys on the blob.
3539
"""
3640
@callback upload(key(), iodata() | File.Stream.t(), Context.t()) ::
37-
:ok | {:error, term()}
41+
:ok | {:ok, map()} | {:error, term()}
3842

3943
@doc """
4044
Download a file from the storage service.

lib/ash_storage/variant_generator.ex

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,8 @@ defmodule AshStorage.VariantGenerator do
110110

111111
blob_resource = Info.storage_blob_resource!(resource)
112112

113-
with :ok <- service_mod.upload(key, variant_data, ctx) do
114-
Ash.create(
115-
blob_resource,
113+
with {:ok, extra_blob_attrs} <- normalize_upload(service_mod.upload(key, variant_data, ctx)) do
114+
blob_attrs =
116115
%{
117116
key: key,
118117
filename: variant_filename,
@@ -125,12 +124,17 @@ defmodule AshStorage.VariantGenerator do
125124
variant_of_blob_id: source_blob.id,
126125
variant_name: to_string(variant_name),
127126
variant_digest: digest
128-
},
129-
action: :create_variant
130-
)
127+
}
128+
|> Map.merge(extra_blob_attrs)
129+
130+
Ash.create(blob_resource, blob_attrs, action: :create_variant)
131131
end
132132
end
133133

134+
defp normalize_upload(:ok), do: {:ok, %{}}
135+
defp normalize_upload({:ok, attrs}) when is_map(attrs), do: {:ok, attrs}
136+
defp normalize_upload({:error, _} = error), do: error
137+
134138
defp resolve_service(resource, attachment_def) do
135139
case Info.service_for_attachment(resource, attachment_def) do
136140
{:ok, service} -> {:ok, service}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
defmodule AshStorage.ExtraBlobAttrsTest do
2+
use ExUnit.Case, async: false
3+
4+
setup do
5+
AshStorage.Service.Test.reset!()
6+
:ok
7+
end
8+
9+
defp create_post!(title \\ "test post") do
10+
AshStorage.Test.ExtraAttrsPost
11+
|> Ash.Changeset.for_create(:create, %{title: title})
12+
|> Ash.create!()
13+
end
14+
15+
describe "Operations.attach with extra blob attrs" do
16+
test "service returning {:ok, map} merges attrs into blob" do
17+
post = create_post!()
18+
19+
{:ok, %{blob: blob}} =
20+
AshStorage.Operations.attach(post, :cover_image, "hello",
21+
filename: "hello.txt",
22+
content_type: "text/plain"
23+
)
24+
25+
assert blob.metadata["injected"] == "from_service"
26+
assert blob.metadata["key"] == blob.key
27+
end
28+
29+
test "service extra attrs merge with caller-provided metadata" do
30+
post = create_post!()
31+
32+
{:ok, %{blob: blob}} =
33+
AshStorage.Operations.attach(post, :cover_image, "hello",
34+
filename: "hello.txt",
35+
metadata: %{"user_tag" => "important"}
36+
)
37+
38+
# Service returns metadata that replaces the caller metadata via Map.merge.
39+
# The extra_blob_attrs map has a :metadata key that overwrites the original.
40+
# This is expected — the service has final say on the metadata field.
41+
assert blob.metadata["injected"] == "from_service"
42+
end
43+
end
44+
45+
describe "AttachFile change with extra blob attrs" do
46+
test "create action merges service attrs into blob" do
47+
path = Path.join(System.tmp_dir!(), "extra_attrs_create.txt")
48+
File.write!(path, "create test data")
49+
50+
post =
51+
AshStorage.Test.ExtraAttrsPost
52+
|> Ash.Changeset.for_create(:create_with_image, %{
53+
title: "with image",
54+
cover_image: Ash.Type.File.from_path(path)
55+
})
56+
|> Ash.create!()
57+
58+
post = Ash.load!(post, cover_image: :blob)
59+
blob = post.cover_image.blob
60+
61+
assert blob.metadata["injected"] == "from_service"
62+
assert blob.metadata["key"] == blob.key
63+
after
64+
File.rm(Path.join(System.tmp_dir!(), "extra_attrs_create.txt"))
65+
end
66+
67+
test "update action merges service attrs into blob" do
68+
post = create_post!()
69+
70+
path = Path.join(System.tmp_dir!(), "extra_attrs_update.txt")
71+
File.write!(path, "update test data")
72+
73+
post =
74+
post
75+
|> Ash.Changeset.for_update(:update_cover_image, %{
76+
cover_image: Ash.Type.File.from_path(path)
77+
})
78+
|> Ash.update!()
79+
80+
post = Ash.load!(post, cover_image: :blob)
81+
blob = post.cover_image.blob
82+
83+
assert blob.metadata["injected"] == "from_service"
84+
assert blob.metadata["key"] == blob.key
85+
after
86+
File.rm(Path.join(System.tmp_dir!(), "extra_attrs_update.txt"))
87+
end
88+
end
89+
90+
describe "VariantGenerator with extra blob attrs" do
91+
test "eager variant blob gets service extra attrs" do
92+
post = create_post!()
93+
94+
{:ok, %{blob: blob}} =
95+
AshStorage.Operations.attach(post, :document, "hello world",
96+
filename: "test.txt",
97+
content_type: "text/plain"
98+
)
99+
100+
blob = Ash.load!(blob, :variants)
101+
variant = Enum.find(blob.variants, &(&1.variant_name == "eager_uppercase"))
102+
103+
assert variant != nil
104+
assert variant.metadata["injected"] == "from_service"
105+
assert variant.metadata["key"] == variant.key
106+
107+
# Verify the variant data was actually uploaded correctly
108+
assert {:ok, "HELLO WORLD"} = AshStorage.Service.Test.download(variant.key, [])
109+
end
110+
end
111+
end

test/support/domain.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@ defmodule AshStorage.Test.Domain do
1212
resource AshStorage.Test.ConfigurablePost
1313
resource AshStorage.Test.AnalyzablePost
1414
resource AshStorage.Test.VariantPost
15+
resource AshStorage.Test.ExtraAttrsPost
1516
end
1617
end

test/support/extra_attrs_post.ex

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
defmodule AshStorage.Test.ExtraAttrsPost do
2+
@moduledoc false
3+
use Ash.Resource,
4+
domain: AshStorage.Test.Domain,
5+
data_layer: Ash.DataLayer.Ets,
6+
extensions: [AshStorage]
7+
8+
ets do
9+
private? true
10+
end
11+
12+
storage do
13+
service({AshStorage.Test.ExtraAttrsService, []})
14+
blob_resource(AshStorage.Test.Blob)
15+
attachment_resource(AshStorage.Test.PolymorphicAttachment)
16+
17+
has_one_attached(:cover_image)
18+
19+
has_one_attached :document do
20+
variant(:eager_uppercase, AshStorage.Test.UppercaseVariant, generate: :eager)
21+
end
22+
end
23+
24+
attributes do
25+
uuid_primary_key :id
26+
attribute :title, :string, allow_nil?: false
27+
end
28+
29+
actions do
30+
defaults [:read, :destroy, create: [:title], update: [:title]]
31+
32+
create :create_with_image do
33+
accept [:title]
34+
argument :cover_image, :file, allow_nil?: true
35+
36+
change {AshStorage.Changes.AttachFile, argument: :cover_image, attachment: :cover_image}
37+
end
38+
39+
update :update_cover_image do
40+
argument :cover_image, :file, allow_nil?: true
41+
42+
change {AshStorage.Changes.AttachFile, argument: :cover_image, attachment: :cover_image}
43+
end
44+
end
45+
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
defmodule AshStorage.Test.ExtraAttrsService do
2+
@moduledoc false
3+
@behaviour AshStorage.Service
4+
5+
@doc """
6+
A test service wrapper that returns extra blob attributes from upload/3.
7+
Delegates storage to the Test service but returns `{:ok, map()}` with
8+
additional metadata to verify the extra_blob_attrs merge path.
9+
"""
10+
11+
@impl true
12+
def upload(key, data, ctx) do
13+
case AshStorage.Service.Test.upload(key, data, ctx) do
14+
:ok -> {:ok, %{metadata: %{"injected" => "from_service", "key" => key}}}
15+
error -> error
16+
end
17+
end
18+
19+
@impl true
20+
def download(key, ctx), do: AshStorage.Service.Test.download(key, ctx)
21+
22+
@impl true
23+
def delete(key, ctx), do: AshStorage.Service.Test.delete(key, ctx)
24+
25+
@impl true
26+
def exists?(key, ctx), do: AshStorage.Service.Test.exists?(key, ctx)
27+
28+
@impl true
29+
def url(key, ctx), do: AshStorage.Service.Test.url(key, ctx)
30+
31+
@impl true
32+
def direct_upload(key, ctx), do: AshStorage.Service.Test.direct_upload(key, ctx)
33+
end

0 commit comments

Comments
 (0)