From d61794f822ecfe1c72b15253e794fb4edfdd8da6 Mon Sep 17 00:00:00 2001 From: Kurt Hogarth <87607684+cylkdev@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:41:09 -0400 Subject: [PATCH 1/5] add get_object/3 function --- lib/cloud_cache.ex | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/cloud_cache.ex b/lib/cloud_cache.ex index e609ca6..5514589 100644 --- a/lib/cloud_cache.ex +++ b/lib/cloud_cache.ex @@ -61,6 +61,10 @@ defmodule CloudCache do adapter(opts).pre_sign(bucket, object, opts) end + def get_object(bucket, object, opts \\ []) do + adapter(opts).get_object(bucket, object, opts) + end + def put_object(bucket, object, body, opts \\ []) do adapter(opts).put_object(bucket, object, body, opts) end From 33d1d9bbf6a2e82eea54ae49a91cb4fde91950ca Mon Sep 17 00:00:00 2001 From: Kurt Hogarth <87607684+cylkdev@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:54:28 -0400 Subject: [PATCH 2/5] add list_buckets function --- lib/cloud_cache.ex | 5 ++- lib/cloud_cache/adapter.ex | 2 ++ lib/cloud_cache/adapters/s3.ex | 36 +++++++++++++++++++ .../adapters/s3/testing/sandbox.ex | 35 ++++++++++++++++++ test/cloud_cache/adapters/s3_sandbox_test.exs | 24 +++++++++++++ test/cloud_cache/adapters/s3_test.exs | 13 +++++++ 6 files changed, 114 insertions(+), 1 deletion(-) diff --git a/lib/cloud_cache.ex b/lib/cloud_cache.ex index 5514589..9202d97 100644 --- a/lib/cloud_cache.ex +++ b/lib/cloud_cache.ex @@ -7,7 +7,6 @@ defmodule CloudCache do @default_adapter CloudCache.Adapters.S3 @default_name __MODULE__ - def start_link(caches, opts \\ []) do Supervisor.start_link(__MODULE__, caches, Keyword.put_new(opts, :name, @default_name)) end @@ -53,6 +52,10 @@ defmodule CloudCache do # Non-Multipart Upload API + def list_buckets(opts \\ []) do + adapter(opts).list_buckets(opts) + end + def head_object(bucket, object, opts \\ []) do adapter(opts).head_object(bucket, object, opts) end diff --git a/lib/cloud_cache/adapter.ex b/lib/cloud_cache/adapter.ex index 588b487..e6bcf03 100644 --- a/lib/cloud_cache/adapter.ex +++ b/lib/cloud_cache/adapter.ex @@ -10,6 +10,8 @@ defmodule CloudCache.Adapter do @callback supervisor_children(opts :: options()) :: list() + @callback list_buckets(opts :: options()) :: {:ok, term()} | {:error, term()} + @callback head_object( bucket :: bucket(), object :: object(), diff --git a/lib/cloud_cache/adapters/s3.ex b/lib/cloud_cache/adapters/s3.ex index a03a7a3..0f82dad 100644 --- a/lib/cloud_cache/adapters/s3.ex +++ b/lib/cloud_cache/adapters/s3.ex @@ -127,6 +127,32 @@ defmodule CloudCache.Adapters.S3 do defp uri_scheme("http" <> _), do: "http://" defp uri_scheme(_), do: "https://" + @impl true + def list_buckets(opts \\ []) do + opts = Keyword.merge(@default_options, opts) + + sandbox? = opts[:s3][:sandbox_enabled] === true + + if not sandbox? or sandbox_disabled?() do + case opts + |> Keyword.take([:host, :port, :region, :scheme, :headers, :timeout]) + |> S3.list_buckets() + |> perform(opts) do + {:ok, %{body: body}} -> + {:ok, body.buckets} + + {:error, %{status: status} = response} when status in 400..499 -> + {:error, ErrorMessage.not_found("buckets not found", %{response: response})} + + {:error, reason} -> + {:error, + ErrorMessage.service_unavailable("service temporarily unavailable", %{reason: reason})} + end + else + sandbox_list_buckets_response(opts) + end + end + @impl true def head_object(bucket, object, opts \\ []) do opts = Keyword.merge(@default_options, opts) @@ -935,6 +961,10 @@ defmodule CloudCache.Adapters.S3 do if Mix.env() === :test do defdelegate sandbox_disabled?, to: CloudCache.Adapters.S3.Testing.S3Sandbox + defdelegate sandbox_list_buckets_response(opts), + to: CloudCache.Adapters.S3.Testing.S3Sandbox, + as: :list_buckets_response + defdelegate sandbox_head_object_response(bucket, object, opts), to: CloudCache.Adapters.S3.Testing.S3Sandbox, as: :head_object_response @@ -1039,6 +1069,12 @@ defmodule CloudCache.Adapters.S3 do else defp sandbox_disabled?, do: true + defp sandbox_list_buckets_response(opts) do + raise """ + Cannot use #{inspect(__MODULE__)}.list_buckets/1 outside of test. + """ + end + defp sandbox_head_object_response(bucket, object, opts) do raise """ Cannot use #{inspect(__MODULE__)}.head_object/3 outside of test. diff --git a/lib/cloud_cache/adapters/s3/testing/sandbox.ex b/lib/cloud_cache/adapters/s3/testing/sandbox.ex index 6f871eb..43d117e 100644 --- a/lib/cloud_cache/adapters/s3/testing/sandbox.ex +++ b/lib/cloud_cache/adapters/s3/testing/sandbox.ex @@ -49,6 +49,37 @@ if Mix.env() === :test do end end + @doc """ + Returns the registered response function for `list_buckets/1` in the + context of the calling process. + """ + def list_buckets_response(opts \\ []) do + doc_examples = + [ + "fn -> ...", + "fn (options) -> ..." + ] + + func = find!(:list_buckets, "*", doc_examples) + + case :erlang.fun_info(func)[:arity] do + 0 -> + func.() + + 1 -> + func.(opts) + + _ -> + raise """ + This function's signature is not supported: #{inspect(func)} + + Please provide a function with one of the following arities (0-#{length(doc_examples) - 1}): + + #{Enum.map_join(doc_examples, "\n", &(" " <> &1))} + """ + end + end + @doc """ Returns the registered response function for `get_object/3` in the context of the calling process. @@ -721,6 +752,10 @@ if Mix.env() === :test do end} ]) """ + def set_list_buckets_responses(funcs) do + set_responses(:list_buckets, Enum.map(funcs, fn f -> {"*", f} end)) + end + def set_head_object_responses(tuples) do set_responses(:head_object, tuples) end diff --git a/test/cloud_cache/adapters/s3_sandbox_test.exs b/test/cloud_cache/adapters/s3_sandbox_test.exs index dfdeee7..ba4a048 100644 --- a/test/cloud_cache/adapters/s3_sandbox_test.exs +++ b/test/cloud_cache/adapters/s3_sandbox_test.exs @@ -9,6 +9,30 @@ defmodule CloudCache.Adapters.S3.Testing.S3SandboxTest do @object "test-object" @options [s3: [sandbox_enabled: true]] + describe "list_objects/1" do + test "returns all buckets" do + S3Sandbox.set_list_buckets_responses([ + fn -> + {:ok, + [ + %{ + name: "test-bucket", + creation_date: ~U[2025-09-30 20:48:01.000Z] + } + ]} + end + ]) + + assert {:ok, + [ + %{ + name: "test-bucket", + creation_date: ~U[2025-09-30 20:48:01.000Z] + } + ]} = S3.list_buckets(@options) + end + end + describe "head_object/3" do test "returns object metadata on success" do S3Sandbox.set_head_object_responses([ diff --git a/test/cloud_cache/adapters/s3_test.exs b/test/cloud_cache/adapters/s3_test.exs index 06b601e..91df7cc 100644 --- a/test/cloud_cache/adapters/s3_test.exs +++ b/test/cloud_cache/adapters/s3_test.exs @@ -6,6 +6,19 @@ defmodule CloudCache.Adapters.S3Test do @bucket "test-bucket" @options [s3: [sandbox_enabled: false]] + describe "list_buckets/3" do + test "returns all buckets" do + assert {:ok, + [ + %{ + name: "test-bucket", + # ~U[2025-09-30 20:48:01.000Z] + creation_date: _ + } + ]} = S3.list_buckets(@options) + end + end + describe "head_object/3" do test "returns object metadata on success" do dest_object = "test_#{:erlang.unique_integer()}.txt" From 6c61b689a0bf772c8c28599b9211f7634d03bb86 Mon Sep 17 00:00:00 2001 From: Kurt Hogarth <87607684+cylkdev@users.noreply.github.com> Date: Tue, 30 Sep 2025 17:43:28 -0400 Subject: [PATCH 3/5] adds support for aws authentication methods --- lib/cloud_cache/adapters/s3.ex | 159 ++++++++++++++++++--------------- mix.exs | 1 + mix.lock | 1 + 3 files changed, 87 insertions(+), 74 deletions(-) diff --git a/lib/cloud_cache/adapters/s3.ex b/lib/cloud_cache/adapters/s3.ex index 0f82dad..e2b84ab 100644 --- a/lib/cloud_cache/adapters/s3.ex +++ b/lib/cloud_cache/adapters/s3.ex @@ -20,109 +20,118 @@ defmodule CloudCache.Adapters.S3 do @behaviour CloudCache.Adapter @logger_prefix "CloudCache.Adapters.S3" + @one_minute_seconds 60 - @mix_env_test Mix.env() === :test + @localhost_scheme "http://" + @localhost_host "s3.localhost.localstack.cloud" + @localhost_port 4566 - @one_minute_seconds 60 - @http_client CloudCache.Adapters.S3.HTTP - @region "us-west-1" - @sandbox_scheme "http://" - @sandbox_host "s3.localhost.localstack.cloud" - @sandbox_port 4566 @default_retry_options [ - max_attempts: if(@mix_env_test, do: 1, else: 10), + max_attempts: if(Mix.env() === :test, do: 1, else: 10), base_backoff_in_ms: 10, max_backoff_in_ms: 10_000 ] - @default_options [ - s3: [ - sandbox_enabled: @mix_env_test, - sandbox: [ - scheme: @sandbox_scheme, - host: @sandbox_host, - port: @sandbox_port - ], - http_client: @http_client, - region: @region, - access_key_id: if(@mix_env_test, do: "test", else: ""), - secret_access_key: if(@mix_env_test, do: "test", else: ""), - retries: @default_retry_options - ] - ] - @s3_config_keys [ - :port, - :scheme, - :host, - :http_client, - :access_key_id, - :secret_access_key, - :region, - :json_codec, - :retries, - :normalize_path, - :require_imds_v2 + @default_s3_options [ + sandbox_enabled: Mix.env() === :test, + local_stack_enabled: Mix.env() === :test, + http_client: CloudCache.Adapters.S3.HTTP, + region: "us-west-1", + retries: @default_retry_options ] + @default_options [s3: @default_s3_options] + # 64 MiB (67_108_864 bytes) @sixty_four_mib 64 * 1_024 * 1_024 @doc """ Returns the S3 configuration as a map. - CloudCache.Adapters.S3.config() + ### Examples + + iex> CloudCache.Adapters.S3.config() """ def config(opts \\ []) do - opts = - Keyword.merge(@default_options, opts, fn - _k, v1, v2 when is_list(v2) -> Keyword.merge(v1, v2) - _k, v1, v2 when is_map(v2) -> Map.merge(v1, v2) - _, _v1, v2 -> v2 - end) + s3_opts = Keyword.merge(@default_s3_options, opts[:s3] || []) - sandbox_opts = - if @mix_env_test do - sandbox_opts = opts[:sandbox] || [] - - case sandbox_opts[:endpoint] do - nil -> - [ - scheme: uri_scheme(sandbox_opts[:scheme] || @sandbox_scheme), - host: sandbox_opts[:host] || @sandbox_host, - port: sandbox_opts[:port] || @sandbox_port - ] - - uri -> - uri = URI.parse(uri) - scheme = uri_scheme(uri.scheme || @sandbox_scheme) - host = uri.host || @sandbox_host - port = uri.port || @sandbox_port - - [ - scheme: scheme, - host: host, - port: port - ] - end - else - [] - end + s3_endpoint_opts = s3_endpoint_options(s3_opts) overrides = - :ex_aws - |> Application.get_all_env() - |> Keyword.merge(opts[:s3] || []) + s3_opts |> Keyword.update( :retries, @default_retry_options, &Keyword.merge(@default_retry_options, &1) ) - |> then(&Keyword.merge(sandbox_opts, &1)) - |> Keyword.take(@s3_config_keys) + |> Keyword.merge(s3_endpoint_opts) + |> Keyword.take([ + :access_key_id, + :host, + :http_client, + :json_codec, + :normalize_path, + :port, + :region, + :require_imds_v2, + :retries, + :secret_access_key, + :scheme + ]) ExAws.Config.new(:s3, overrides) end + defp s3_endpoint_options(opts) do + if opts[:local_stack_enabled] do + [ + scheme: @localhost_scheme, + host: @localhost_host, + port: @localhost_port, + access_key_id: "test", + secret_access_key: "test" + ] + else + creds = aws_credentials_opts(opts) + + base = + case opts[:endpoint] do + nil -> + [] + + endpoint -> + uri = URI.parse(endpoint) + + [ + scheme: uri_scheme(uri.scheme), + host: uri.host, + port: uri.port + ] + end + + Keyword.merge(base, creds) + end + end + + defp aws_credentials_opts(opts) do + profile = Keyword.get(opts, :profile, "cloud_cache") + + [ + access_key_id: [ + {:awscli, profile, 30}, + :instance_role, + {:system, "AWS_ACCESS_KEY_ID"}, + "" + ], + secret_access_key: [ + {:awscli, profile, 30}, + :instance_role, + {:system, "AWS_SECRET_ACCESS_KEY"}, + "" + ] + ] + end + defp uri_scheme("https" <> _), do: "https://" defp uri_scheme("http" <> _), do: "http://" defp uri_scheme(_), do: "https://" @@ -1072,6 +1081,8 @@ defmodule CloudCache.Adapters.S3 do defp sandbox_list_buckets_response(opts) do raise """ Cannot use #{inspect(__MODULE__)}.list_buckets/1 outside of test. + + options: #{inspect(opts)} """ end diff --git a/mix.exs b/mix.exs index 5df40f8..ca85b27 100644 --- a/mix.exs +++ b/mix.exs @@ -47,6 +47,7 @@ defmodule CloudCache.MixProject do [ {:ex_aws, "~> 2.0", optional: true}, {:ex_aws_s3, "~> 2.0", optional: true}, + {:configparser_ex, ">= 0.0.0", optional: true}, {:sweet_xml, ">= 0.0.0", optional: true}, {:proper_case, "~> 1.0", optional: true}, {:timex, "~> 3.0", optional: true}, diff --git a/mix.lock b/mix.lock index 9b1edd2..cc6f37d 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,7 @@ %{ "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, + "configparser_ex": {:hex, :configparser_ex, "5.0.0", "2b37c5022500fc0a47245dad7c2a8df8d2b41441ed348c162de422965a787d0c", [:mix], [], "hexpm", "404319c6181d38e7cf2f6c4b8ce6d6cdaebbde5538a047ec29937cf7198b6b29"}, "error_message": {:hex, :error_message, "0.3.3", "493b6edae73a438647db843a4ba3e2a45baeff4c0c94236fca7f77e2ada2806a", [:mix], [{:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "671a0a78708b299d119acdadc5f771c8c3cc4b171a9562745936bfe117674b73"}, "ex_aws": {:hex, :ex_aws, "2.5.11", "5646eaad701485505b78246b0cd406fde9b1619459a86e85b53398810d3d0bd3", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7e16100ff93a118ef01c916d945969535cbe8d4ab6593fcf01d1cf854eb75345"}, "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.8", "5ee7407bc8252121ad28fba936b3b293f4ecef93753962351feb95b8a66096fa", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "84e512ca2e0ae6a6c497036dff06d4493ffb422cfe476acc811d7c337c16691c"}, From fee24e4f2085a8c060a4dcbf5b6ef8d4e19d324e Mon Sep 17 00:00:00 2001 From: Tyrone Taylor <28680107+ttaylor92@users.noreply.github.com> Date: Tue, 30 Sep 2025 21:36:33 -0500 Subject: [PATCH 4/5] feat: implemented list_multipart_uploads functions --- lib/cloud_cache/adapter.ex | 5 +++ lib/cloud_cache/adapters/s3.ex | 30 +++++++++++++ .../adapters/s3/testing/sandbox.ex | 39 ++++++++++++++++ test/cloud_cache/adapters/s3_sandbox_test.exs | 45 +++++++++++++++++++ 4 files changed, 119 insertions(+) diff --git a/lib/cloud_cache/adapter.ex b/lib/cloud_cache/adapter.ex index e6bcf03..7ef04de 100644 --- a/lib/cloud_cache/adapter.ex +++ b/lib/cloud_cache/adapter.ex @@ -95,6 +95,11 @@ defmodule CloudCache.Adapter do opts :: options() ) :: {:ok, term()} | {:error, term()} + @callback list_multipart_uploads( + bucket :: bucket(), + opts :: options() + ) :: {:ok, term()} | {:error, term()} + @callback copy_object_multipart( dest_bucket :: bucket(), dest_object :: object(), diff --git a/lib/cloud_cache/adapters/s3.ex b/lib/cloud_cache/adapters/s3.ex index e2b84ab..5cf0551 100644 --- a/lib/cloud_cache/adapters/s3.ex +++ b/lib/cloud_cache/adapters/s3.ex @@ -485,6 +485,32 @@ defmodule CloudCache.Adapters.S3 do end end + @impl true + @doc """ + ... + """ + def list_multipart_uploads(bucket, opts \\ []) do + opts = Keyword.merge(@default_options, opts) + + sandbox? = opts[:s3][:sandbox_enabled] === true + + if not sandbox? or sandbox_disabled?() do + case bucket |> S3.list_multipart_uploads(opts) |> perform(opts) do + {:ok, %{body: %{uploads: uploads}}} -> + {:ok, uploads} + + {:error, reason} -> + {:error, + ErrorMessage.service_unavailable("service temporarily unavailable", %{ + bucket: bucket, + reason: reason + })} + end + else + sandbox_list_multipart_uploads_response(bucket, opts) + end + end + @impl true @doc """ ... @@ -1023,6 +1049,10 @@ defmodule CloudCache.Adapters.S3 do to: CloudCache.Adapters.S3.Testing.S3Sandbox, as: :pre_sign_part_response + defdelegate sandbox_list_multipart_uploads_response(bucket, opts), + to: CloudCache.Adapters.S3.Testing.S3Sandbox, + as: :list_multipart_uploads_response + defdelegate sandbox_copy_object_multipart_response( dest_bucket, dest_object, diff --git a/lib/cloud_cache/adapters/s3/testing/sandbox.ex b/lib/cloud_cache/adapters/s3/testing/sandbox.ex index 43d117e..7105cea 100644 --- a/lib/cloud_cache/adapters/s3/testing/sandbox.ex +++ b/lib/cloud_cache/adapters/s3/testing/sandbox.ex @@ -605,6 +605,41 @@ if Mix.env() === :test do end end + @doc """ + Returns the registered response function for `list_multipart_uploads/2` + in the context of the calling process. + """ + def list_multipart_uploads_response(bucket, opts \\ []) do + doc_examples = + [ + "fn -> ...", + "fn (bucket) -> ...", + "fn (bucket, options) -> ..." + ] + + func = find!(:list_multipart_uploads, bucket, doc_examples) + + case :erlang.fun_info(func)[:arity] do + 0 -> + func.() + + 1 -> + func.(bucket) + + 2 -> + func.(bucket, opts) + + _ -> + raise """ + This function's signature is not supported: #{inspect(func)} + + Please provide a function with one of the following arities (0-#{length(doc_examples) - 1}): + + #{Enum.map_join(doc_examples, "\n", &(" " <> &1))} + """ + end + end + @doc """ Returns the registered response function for `complete_multipart_upload/5` in the context of the calling process. @@ -808,6 +843,10 @@ if Mix.env() === :test do set_responses(:complete_multipart_upload, tuples) end + def set_list_multipart_uploads_responses(tuples) do + set_responses(:list_multipart_uploads, tuples) + end + def set_abort_multipart_upload_responses(tuples) do set_responses(:abort_multipart_upload, tuples) end diff --git a/test/cloud_cache/adapters/s3_sandbox_test.exs b/test/cloud_cache/adapters/s3_sandbox_test.exs index ba4a048..52720b4 100644 --- a/test/cloud_cache/adapters/s3_sandbox_test.exs +++ b/test/cloud_cache/adapters/s3_sandbox_test.exs @@ -513,6 +513,51 @@ defmodule CloudCache.Adapters.S3.Testing.S3SandboxTest do end end + describe "list_multipart_uploads/2" do + test "returns list of multipart uploads on success" do + S3Sandbox.set_list_multipart_uploads_responses([ + {@bucket, + fn -> + {:ok, + [ + %{ + bucket: @bucket, + key: @object, + upload_id: "upload_id_123" + } + ]} + end} + ]) + + assert {:ok, response} = + S3.list_multipart_uploads(@bucket, @options) + + assert Enum.any?(response, fn upload -> + upload.key === @object and upload.upload_id === "upload_id_123" + end) + end + + test "returns service_unavailable error on failure to list multipart uploads" do + S3Sandbox.set_list_multipart_uploads_responses([ + {@bucket, + fn -> + {:error, + %ErrorMessage{ + code: :service_unavailable, + message: "service temporarily unavailable" + }} + end} + ]) + + assert {:error, + %ErrorMessage{ + code: :service_unavailable, + message: "service temporarily unavailable" + }} = + S3.list_multipart_uploads(@bucket, @options) + end + end + describe "complete_multipart_upload/5" do test "returns file metadata on successful multipart upload completion" do S3Sandbox.set_complete_multipart_upload_responses([ From dcfa509e8e06ecd2058336fd1b5897b90546438e Mon Sep 17 00:00:00 2001 From: Tyrone Taylor <28680107+ttaylor92@users.noreply.github.com> Date: Tue, 30 Sep 2025 21:40:05 -0500 Subject: [PATCH 5/5] chore: added adapter method --- lib/cloud_cache/adapter.ex | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/cloud_cache/adapter.ex b/lib/cloud_cache/adapter.ex index 7ef04de..b260cfb 100644 --- a/lib/cloud_cache/adapter.ex +++ b/lib/cloud_cache/adapter.ex @@ -171,6 +171,10 @@ defmodule CloudCache.Adapter do adapter.list_parts(bucket, object, upload_id, opts) end + def list_multipart_uploads(adapter, bucket, opts \\ []) do + adapter.list_multipart_uploads(bucket, opts) + end + def complete_multipart_upload(adapter, bucket, object, upload_id, parts, opts \\ []) do adapter.complete_multipart_upload(bucket, object, upload_id, parts, opts) end