ExOss supports file transfer operations via ExOss.StorageTask structs and the run/1 macro function (or ExOss.Runner.run/2).

StorageTask

An ExOss.StorageTask struct has source, target, and opts fields.

Location Types

TypeFormatDescription
Cloud{:cloud, bucket, key}Object in cloud storage
Remote URL{:remote, url}Download from a URL
Local file{:file, path}Upload from filesystem
Content{:content, binary}In-memory binary

Operations

Upload from Local File

MyApp.Storage.run(%ExOss.StorageTask{
  source: {:file, "/path/to/photo.jpg"},
  target: {:cloud, "bucket", "photos/photo.jpg"}
})
# => {:ok, "photos/photo.jpg"}

Local files use streaming multipart uploads, even for small files. S3 uploads without raw headers use ExAws.S3.upload; S3 uploads with opts[:headers] and native OSS uploads use ExOss's multipart coordinator so object headers are set before signing initiation. The coordinator accepts chunk_size (at least 5 MiB), max_concurrency (default 4 for S3, 1 for OSS), and per-part timeout (default 30000 milliseconds or :infinity). It aborts on part failures/timeouts. S3 credentials are resolved for each request on this path, including each part. sweet_xml parses the service's XML responses to read the initial UploadId and the completion result; it does not parse your file's contents or handle HTTP requests. Req handles HTTP transport, and ExOss automatically includes sweet_xml as a required dependency—no separate installation is needed.

Content and remote URL uploads use a regular PUT rather than multipart upload, so they do not rely on this multipart response parsing.

Upload from Content

MyApp.Storage.run(%ExOss.StorageTask{
  source: {:content, "Hello, World!"},
  target: {:cloud, "bucket", "hello.txt"}
})
# => {:ok, "hello.txt"}

Upload from Remote URL

MyApp.Storage.run(%ExOss.StorageTask{
  source: {:remote, "https://example.com/photo.jpg"},
  target: {:cloud, "bucket", "photos/photo.jpg"}
})
# => {:ok, "photos/photo.jpg"}

The remote content is downloaded via Req.get/2 and then uploaded to the configured storage provider. The response's Content-Type is used unless the caller supplies a content type or an explicit header.

Cloud-to-Cloud Copy

MyApp.Storage.run(%ExOss.StorageTask{
  source: {:cloud, "source-bucket", "file.jpg"},
  target: {:cloud, "target-bucket", "copies/file.jpg"}
})
# => {:ok, "copies/file.jpg"}

This uses server-side CopyObject (S3 or OSS). OSS copying requires both buckets to be in the same region.

Delete Immediately

MyApp.Storage.run(%ExOss.StorageTask{
  source: {:cloud, "bucket", "file.jpg"},
  target: nil
})
# => {:ok, nil}

Delete After N Days (Delayed Delete)

MyApp.Storage.run(%ExOss.StorageTask{
  source: {:cloud, "bucket", "file.jpg"},
  target: nil,
  opts: [days: 30]
})
# => {:ok, nil}

This adds a delete_after_days tag to the object. A lifecycle rule must be configured on the bucket to actually expire tagged objects.

Content-Type Detection

For {:file, path} and {:content, binary} sources, ExOss automatically detects the content type using MIME.from_path/1. To override:

MyApp.Storage.run(%ExOss.StorageTask{
  source: {:content, binary_data},
  target: {:cloud, "bucket", "file.dat"},
  opts: [content_type: "application/x-custom"]
})

Error Handling

All operations return {:ok, result} or {:error, reason}:

case MyApp.Storage.run(task) do
  {:ok, res_key} -> IO.puts("Uploaded to #{res_key}")
  {:error, reason} -> IO.puts("Failed: #{reason}")
end

Using with Explicit Client

For multi-client scenarios:

client = ExOss.Client.new(provider: :aws, ...)
ExOss.Runner.run(client, %ExOss.StorageTask{...})

Raw Headers and Presigned Uploads

Uploads accept opts[:headers] as a map or list of string pairs:

opts: [
  headers: %{"Content-Type" => "image/png", "Cache-Control" => "max-age=3600"}
]

The outer options may also be a map. Both opts[:content_type] and opts["content_type"] place their value in headers["content-type"]. This applies to all storage providers (:aws, :qiniu, :custom, :aliyun) for server uploads and presigned PUT credentials. It is an option-to-header mapping, not a change to the MIME value or a provider-specific conversion. For example:

opts = %{"content_type" => "image/png", "headers" => %{"cache-control" => "max-age=3600"}}
# Effective headers: %{"content-type" => "image/png", "cache-control" => "max-age=3600"}

Only "content_type" and "headers" have string-key aliases; other outer option keys remain atoms. Supplying both a string and atom alias is rejected as ambiguous. Existing keyword options continue to work.

Names are case-insensitive. Explicit headers override opts[:content_type], which remains supported, and automatic inference. Duplicate names differing only by case are rejected. ExOss reserves authentication, host, date, length and transport framing headers. Provider-specific headers are not translated; check the provider's documentation for supported headers on each operation.

For presigned uploads, pass the same options as the new final argument:

credential = MyApp.Storage.upload_credential("bucket", "photo.png", 3600,
  content_type: "image/png",
  headers: %{"cache-control" => "max-age=3600"}
)
Req.put!(credential.presigned_url, body: png_bytes, headers: credential.headers)

Existing calls without options still work. No Content-Type is automatically inferred for a presigned URL; explicitly supplied headers are included in the storage signature. Changing their values can invalidate the signature. Browser uploads need a CORS rule permitting PUT and the supplied headers.

See Aliyun OSS for native OSS details and Aliyun CDN for CDN upload fallback and URL signing.

The default Req transport preserves signed fields even if application/global Req options specify auth, params, body encoders, or replacement headers. Use opts[:headers] for business headers, not transport configuration. Explicit custom ExAws HTTP clients are still honored and must preserve signed requests.