fix: return raw bytes from S3 and AzureBlob downloads - #26
Conversation
48b25b1 to
0830cfb
Compare
Req's default `decode_body` step deserialises response bodies based on
the `Content-Type` header — JSON to maps, CSV to lists of lists, tar/zip
archives transparently extracted, etc. Both `AshStorage.Service.AzureBlob.download/2`
and `AshStorage.Service.S3.download/2` are documented to return
`{:ok, binary()}`, but they call `Req.get/2` without opting out of the
decoder. Any blob the cloud returns with a recognised Content-Type
(text/csv, application/json, application/zip, ...) comes back to the
caller pre-parsed instead of as bytes.
This also silently breaks the `verify_md5/2` check that runs right after
the download: when the body is decoded, MD5 is computed over the iodata
flattening, not the original bytes.
The fix passes `decode_body: false` to `Req.get/2` so the body comes back
as-is, matching the typespec. `decode_body: false` is preferred over
`raw: true` because the latter also disables `decompress_body`, which we
want to keep for transparent gzip/br handling.
Callers that intentionally want Req's content-type-based decoding can
opt back in via `service_opts: [decode_body: true]` on either service.
The new option is documented in `service_opts_fields/0`.
Integration tests cover both directions for AzureBlob and S3.
0830cfb to
6bb3eab
Compare
|
Yeah that's just missing type spec updates. Lets do what you said where the current behavior becomes the default with an option to change. |
|
Hey @zachdaniel , did as you said. Note however because of the decoding there is also a bug in main right now. I created an issue #27 for it. Basically with automatic decoding the checksums will fail as they need raw binary to execute. We can address this in a future PR. |
|
@zachdaniel If that bug changes your mind regarding the default will be happy to switch it back to |
|
🚀 Thank you for your contribution! 🚀 |
|
Ah, I didn't see this last comment until it was too late 🤔. Yeah I guess you're right we should adjust the contract and not decode the body. We wouldn't even really want to have the option to decode if it can break dependents right? |
|
I can revert this PR or if you want to just open a new one with your proposed proper fix. Up to you 🙇♂️ |
|
@zachdaniel Yes please, you can revert and I'll reopen the PR with Cheers |
Summary
AshStorage.Service.AzureBlob.download/2andAshStorage.Service.S3.download/2are typed{:ok, binary()} | {:error, term()}, but they delegate toReq.get/2without disabling Req's defaultdecode_bodystep. That step deserialises response bodies based on the response'sContent-Typeheader:jsonJason.decode/2csvNimbleCSV.RFC4180.parse_string/2(if loaded)tar,tgz:erl_tar.extract/2zip:zip.unzip/2gzip:zlib.gunzip/1zst:ezstd.decompress/1(if loaded)So a blob stored with
Content-Type: application/zipcomes back already unzipped;text/csvcomes back as a list of lists; JSON comes back as a map. The typespec claimsbinary(); the runtime returns whatever Req decided to decode.This was discovered in the wild when a CSV uploaded to chat became unreadable to the downstream parser. The Disk service uses
File.read/1, so unit tests never hit it; only the Azure/S3 paths do.There's also a latent
verify_md5/2interaction: whenbodyis an auto-decoded term, MD5 is computed over the iodata flattening of that term, not the original bytes — silently inconsistent with the digest the server stored.Change
The runtime behaviour is intentional and used by real callers, so the fix is in the typespec, not the code path. The behaviour callback now reflects what services actually return:
The
AshStorage.Service.download/2callback doc explains the contract: auto-decoding by default on theReq-based services, raw bytes on Disk/Mirror.For callers that want the raw uploaded bytes — writing to disk, streaming to a client, verifying a checksum — both
Req-based services accept a new:decode_bodyservice option (defaults totrue):The option is registered in both adapters'
service_opts_fields/0so consumers that introspect the schema see it.Tests
New integration tests in both suites:
test/ash_storage/service/azure_blob_integration_test.exs— JSON auto-decodes to a map by default;decode_body: falsereturns the raw CSV and JSON bytes.test/ash_storage/service/s3_integration_test.exs— same, with a smallraw_put_with_content_type/3helper that setsContent-Typevia a signed PUT (sinceS3.upload/3doesn't currently expose the header).The new tests are tagged
:azure_integration/:s3_integrationlike the existing ones, so they only run when Docker is available.Acceptance Criteria
🤖 Generated with Claude Code