Skip to content

Commit f93e6b6

Browse files
authored
fix: return raw bytes from S3 and AzureBlob downloads (#26)
1 parent 47362c5 commit f93e6b6

5 files changed

Lines changed: 92 additions & 6 deletions

File tree

lib/ash_storage/service.ex

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,18 @@ defmodule AshStorage.Service do
4343
@doc """
4444
Download a file from the storage service.
4545
46-
Returns the file contents as binary data.
46+
By default, services built on `Req` (S3, AzureBlob) run Req's `decode_body`
47+
step, so the returned body reflects the stored object's `content-type` —
48+
`application/json` comes back as a decoded map, `text/csv` as parsed rows,
49+
`application/zip` already unzipped, and so on. File-based services (Disk,
50+
Mirror) always return raw bytes.
51+
52+
Callers that need the exact uploaded bytes — writing to disk, streaming to
53+
a client, verifying a checksum — should pass `decode_body: false` via the
54+
service options on the `Req`-based services.
4755
"""
4856
@callback download(key(), Context.t()) ::
49-
{:ok, binary()} | {:error, term()}
57+
{:ok, term()} | {:error, term()}
5058

5159
@doc """
5260
Delete a file from the storage service.

lib/ash_storage/service/azure_blob.ex

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ if Code.ensure_loaded?(Req) do
4949
(default: `"2020-12-06"`)
5050
- `:signed_protocol` - SAS protocol restriction. Defaults to `"https"`, or
5151
`"https,http"` for `http://` endpoints
52+
- `:decode_body` - whether `download/2` runs Req's content-type response
53+
decoding (JSON → map, CSV → rows, gzip → unzipped, etc.). Defaults to
54+
`true` to match Req's own default; pass `false` when you need the raw
55+
uploaded bytes.
5256
5357
## Azure setup
5458
@@ -109,7 +113,8 @@ if Code.ensure_loaded?(Req) do
109113
expires_in: [type: :integer],
110114
direct_upload_expires_in: [type: :integer],
111115
service_version: [type: :string],
112-
signed_protocol: [type: :string]
116+
signed_protocol: [type: :string],
117+
decode_body: [type: :boolean]
113118
]
114119
end
115120

@@ -148,8 +153,11 @@ if Code.ensure_loaded?(Req) do
148153
def download(key, %AshStorage.Service.Context{} = ctx) do
149154
full_key = prefixed_key(key, ctx)
150155

156+
decode_body? = Keyword.get(ctx.service_opts, :decode_body, true)
157+
151158
with {:ok, url} <- signed_blob_url(full_key, ctx, permissions: "r", expires_in: 900),
152-
{:ok, %{status: 200, body: body}} <- Req.get(url, headers: base_headers(ctx)),
159+
{:ok, %{status: 200, body: body}} <-
160+
Req.get(url, headers: base_headers(ctx), decode_body: decode_body?),
153161
:ok <- verify_md5(body, ctx.expected_md5) do
154162
{:ok, body}
155163
else

lib/ash_storage/service/s3.ex

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ if Code.ensure_loaded?(ReqS3) do
2323
- `:secret_access_key` - AWS secret access key (falls back to `AWS_SECRET_ACCESS_KEY` env var)
2424
- `:endpoint_url` - custom endpoint URL for S3-compatible services (e.g. MinIO, Tigris)
2525
- `:prefix` - optional key prefix (e.g. `"uploads/"`)
26+
- `:decode_body` - whether `download/2` runs Req's content-type response
27+
decoding (JSON → map, CSV → rows, gzip → unzipped, etc.). Defaults to
28+
`true` to match Req's own default; pass `false` when you need the raw
29+
uploaded bytes.
2630
"""
2731

2832
@behaviour AshStorage.Service
@@ -35,7 +39,8 @@ if Code.ensure_loaded?(ReqS3) do
3539
access_key_id: [type: :string],
3640
secret_access_key: [type: :string],
3741
endpoint_url: [type: :string],
38-
prefix: [type: :string]
42+
prefix: [type: :string],
43+
decode_body: [type: :boolean]
3944
]
4045
end
4146

@@ -59,7 +64,10 @@ if Code.ensure_loaded?(ReqS3) do
5964
def download(key, %AshStorage.Service.Context{} = ctx) do
6065
full_key = prefixed_key(key, ctx)
6166

62-
with {:ok, %{status: 200, body: body}} <- Req.get(req(ctx), url: "/#{full_key}"),
67+
decode_body? = Keyword.get(ctx.service_opts, :decode_body, true)
68+
69+
with {:ok, %{status: 200, body: body}} <-
70+
Req.get(req(ctx), url: "/#{full_key}", decode_body: decode_body?),
6371
:ok <- verify_md5(body, ctx.expected_md5) do
6472
{:ok, body}
6573
else

test/ash_storage/service/azure_blob_integration_test.exs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,32 @@ defmodule AshStorage.Service.AzureBlobIntegrationTest do
142142
assert {:error, :not_found} = AzureBlob.download(unique_key(), ctx())
143143
end
144144

145+
test "auto-decodes JSON bodies by default" do
146+
key = unique_key()
147+
json = ~s({"name":"Alice","score":95})
148+
149+
assert :ok = AzureBlob.upload(key, json, ctx(content_type: "application/json"))
150+
151+
assert {:ok, %{"name" => "Alice", "score" => 95}} =
152+
AzureBlob.download(key, ctx())
153+
end
154+
155+
test "decode_body: false returns raw CSV bytes" do
156+
key = unique_key()
157+
csv = "Name,Score\nAlice,95\nBob,87\n"
158+
159+
assert :ok = AzureBlob.upload(key, csv, ctx(content_type: "text/csv"))
160+
assert {:ok, ^csv} = AzureBlob.download(key, ctx(decode_body: false))
161+
end
162+
163+
test "decode_body: false returns raw JSON bytes" do
164+
key = unique_key()
165+
json = ~s({"name":"Alice","score":95})
166+
167+
assert :ok = AzureBlob.upload(key, json, ctx(content_type: "application/json"))
168+
assert {:ok, ^json} = AzureBlob.download(key, ctx(decode_body: false))
169+
end
170+
145171
test "accepts upload when ctx expected_md5 matches the body" do
146172
key = unique_key()
147173
data = "checksum-verified payload"

test/ash_storage/service/s3_integration_test.exs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,24 @@ defmodule AshStorage.Service.S3IntegrationTest do
8888
assert {:error, :not_found} = S3.download(unique_key(), ctx())
8989
end
9090

91+
test "auto-decodes JSON bodies by default" do
92+
key = unique_key()
93+
json = ~s({"name":"Alice","score":95})
94+
95+
:ok = raw_put_with_content_type(key, json, "application/json")
96+
97+
assert {:ok, %{"name" => "Alice", "score" => 95}} =
98+
S3.download(key, ctx())
99+
end
100+
101+
test "decode_body: false returns raw bytes for content-typed objects" do
102+
key = unique_key()
103+
csv = "Name,Score\nAlice,95\nBob,87\n"
104+
105+
:ok = raw_put_with_content_type(key, csv, "text/csv")
106+
assert {:ok, ^csv} = S3.download(key, ctx(decode_body: false))
107+
end
108+
91109
test "accepts upload when ctx expected_md5 matches the body" do
92110
key = unique_key()
93111
data = "checksum-verified payload"
@@ -361,6 +379,24 @@ defmodule AshStorage.Service.S3IntegrationTest do
361379

362380
# -- Helpers --
363381

382+
defp raw_put_with_content_type(key, body, content_type) do
383+
url = "#{@service_opts[:endpoint_url]}/#{@bucket}/#{key}"
384+
385+
{:ok, %{status: status}} =
386+
Req.put(url,
387+
body: body,
388+
headers: %{"content-type" => content_type},
389+
aws_sigv4: [
390+
service: :s3,
391+
region: @service_opts[:region],
392+
access_key_id: @service_opts[:access_key_id],
393+
secret_access_key: @service_opts[:secret_access_key]
394+
]
395+
)
396+
397+
if status in 200..299, do: :ok, else: {:error, status}
398+
end
399+
364400
defp unique_key do
365401
"test/#{System.unique_integer([:positive])}-#{:crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower)}"
366402
end

0 commit comments

Comments
 (0)