diff --git a/lib/tus_client.ex b/lib/tus_client.ex index 739a8a9..a75b2a0 100644 --- a/lib/tus_client.ex +++ b/lib/tus_client.ex @@ -1,8 +1,14 @@ defmodule TusClient do @moduledoc """ - A minimal client for the https://tus.io protocol. + A client for the https://tus.io resumable upload protocol. + + Supports: + - Full upload via `upload/3` + - Resuming interrupted uploads via `resume/3` + - Cancelling uploads via `cancel/2` + - Progress, completion, and error callbacks """ - alias TusClient.{Options, Patch, Post} + alias TusClient.{Delete, Head, Options, Patch, Post} require Logger @@ -10,41 +16,116 @@ defmodule TusClient do :file_error | :generic | :location + | :not_found | :not_supported | :too_large | :too_many_errors | :transport | :unfulfilled_extensions + @doc """ + Upload a file to a TUS server. + + ## Options + + * `:metadata` - map of metadata key/value pairs to send with the upload + * `:max_retries` - max number of retries on patch errors (default: 3) + * `:chunk_len` - chunk size in bytes (default: 4MB) + * `:headers` - additional HTTP headers as a list of `{key, value}` tuples + * `:ssl` - SSL options to pass to HTTPoison + * `:follow_redirect` - whether to follow HTTP redirects (default: false) + * `:on_progress` - callback `fn offset, total_size -> :ok end` called after each chunk + * `:on_complete` - callback `fn location -> :ok end` called when upload finishes + * `:on_error` - callback `fn reason, retry_nr -> :ok end` called before each retry + """ @spec upload( binary(), binary(), list( - {:metadata, binary()} + {:metadata, map()} | {:max_retries, integer()} | {:chunk_len, integer()} | {:headers, list()} | {:ssl, list()} | {:follow_redirect, boolean()} + | {:on_progress, function()} + | {:on_complete, function()} + | {:on_error, function()} ) ) :: {:ok, binary} | {:error, upload_error()} def upload(base_url, path, opts \\ []) do - with {:ok, _} <- Options.request(base_url, get_headers(opts), opts), + with {:ok, %{max_size: max_size}} <- Options.request(base_url, get_headers(opts), opts), + :ok <- check_file_size(path, max_size), {:ok, %{location: loc}} <- Post.request(base_url, path, get_headers(opts), opts) do do_patch(loc, path, opts) end end + @doc """ + Resume an interrupted upload by its location URL. + + Uses HEAD to discover the current offset, then continues from there. + + Accepts the same options as `upload/3` (except `:metadata` which is ignored). + """ + @spec resume(binary(), binary(), list()) :: {:ok, binary()} | {:error, upload_error()} + def resume(location, path, opts \\ []) do + case Head.request(location, get_headers(opts), opts) do + {:ok, %{upload_offset: offset}} -> + total_size = file_size(path) + + if offset >= total_size do + # Upload already complete + call_progress(opts, offset, total_size) + call_complete(opts, location) + {:ok, location} + else + call_progress(opts, offset, total_size) + + location + |> Patch.request(offset, path, get_headers(opts), opts) + |> do_patch(location, path, opts, 0, offset) + end + + {:error, :not_found} -> + {:error, :not_found} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Cancel an in-progress upload by sending a DELETE request. + + ## Options + + * `:headers` - additional HTTP headers + * `:ssl` - SSL options + * `:follow_redirect` - whether to follow HTTP redirects + """ + @spec cancel(binary(), list()) :: :ok | {:error, upload_error()} + def cancel(location, opts \\ []) do + Delete.request(location, get_headers(opts), opts) + end + defp do_patch(location, path, opts) do + total_size = file_size(path) + call_progress(opts, 0, total_size) + location |> Patch.request(0, path, get_headers(opts), opts) |> do_patch(location, path, opts, 1, 0) end defp do_patch({:ok, new_offset}, location, path, opts, _retry_nr, _offset) do - case file_size(path) do + total_size = file_size(path) + call_progress(opts, new_offset, total_size) + + case total_size do ^new_offset -> + call_complete(opts, location) {:ok, location} _ -> @@ -61,7 +142,8 @@ defmodule TusClient do {:error, :too_many_errors} _ -> - Logger.warn("Patch error #{inspect(reason)}, retrying...") + call_on_error(opts, reason, retry_nr) + Logger.warning("Patch error #{inspect(reason)}, retrying...") location |> Patch.request(offset, path, get_headers(opts), opts) @@ -69,6 +151,16 @@ defmodule TusClient do end end + defp check_file_size(_path, nil), do: :ok + + defp check_file_size(path, max_size) do + case File.stat(path) do + {:ok, %{size: size}} when size > max_size -> {:error, :too_large} + {:ok, _} -> :ok + _ -> {:error, :file_error} + end + end + defp file_size(path) do {:ok, %{size: size}} = File.stat(path) size @@ -82,4 +174,27 @@ defmodule TusClient do defp get_headers(opts) do opts |> Keyword.get(:headers, []) end + + # Callback helpers + + defp call_progress(opts, offset, total_size) do + case Keyword.get(opts, :on_progress) do + fun when is_function(fun, 2) -> fun.(offset, total_size) + _ -> :ok + end + end + + defp call_complete(opts, location) do + case Keyword.get(opts, :on_complete) do + fun when is_function(fun, 1) -> fun.(location) + _ -> :ok + end + end + + defp call_on_error(opts, reason, retry_nr) do + case Keyword.get(opts, :on_error) do + fun when is_function(fun, 2) -> fun.(reason, retry_nr) + _ -> :ok + end + end end diff --git a/lib/tus_client/delete.ex b/lib/tus_client/delete.ex new file mode 100644 index 0000000..ae8e54d --- /dev/null +++ b/lib/tus_client/delete.ex @@ -0,0 +1,35 @@ +defmodule TusClient.Delete do + @moduledoc false + alias TusClient.Utils + + require Logger + + def request(url, headers \\ [], opts \\ []) do + hdrs = + headers + |> Utils.add_version_hdr() + |> Enum.uniq() + + url + |> HTTPoison.delete(hdrs, Utils.httpoison_opts([], opts)) + |> Utils.maybe_follow_redirect(&parse/1, &request(&1, headers, opts)) + end + + defp parse({:ok, %{status_code: 204}}) do + :ok + end + + defp parse({:ok, %{status_code: status}}) when status in [404, 410] do + {:error, :not_found} + end + + defp parse({:ok, resp}) do + Logger.error("DELETE response not handled: #{inspect(resp)}") + {:error, :generic} + end + + defp parse({:error, err}) do + Logger.error("DELETE request failed: #{inspect(err)}") + {:error, :transport} + end +end diff --git a/lib/tus_client/options.ex b/lib/tus_client/options.ex index 258d184..9e5090b 100644 --- a/lib/tus_client/options.ex +++ b/lib/tus_client/options.ex @@ -69,7 +69,7 @@ defmodule TusClient.Options do :ok v -> - Logger.warn("Unsupported server version #{v}") + Logger.warning("Unsupported server version #{v}") {:error, :not_supported} end end diff --git a/lib/tus_client/post.ex b/lib/tus_client/post.ex index 6080ec7..d80d0b0 100644 --- a/lib/tus_client/post.ex +++ b/lib/tus_client/post.ex @@ -103,7 +103,7 @@ defmodule TusClient.Post do true false -> - Logger.warn("Discarding invalid key #{k}") + Logger.warning("Discarding invalid key #{k}") false end end) @@ -111,12 +111,10 @@ defmodule TusClient.Post do end defp encode_metadata(md) do - md - |> Enum.map(fn {k, v} -> - value = v |> to_string |> Base.encode64() + Enum.map_join(md, ",", fn {k, v} -> + value = v |> to_string() |> Base.encode64() "#{k} #{value}" end) - |> Enum.join(",") end defp add_custom_headers(hdrs1, hdrs2) do diff --git a/lib/tus_client/utils.ex b/lib/tus_client/utils.ex index b0d5e9d..bfe9128 100644 --- a/lib/tus_client/utils.ex +++ b/lib/tus_client/utils.ex @@ -38,7 +38,7 @@ defmodule TusClient.Utils do rereq_fn.(new_loc) end - # TODO remove this when https://github.com/edgurgel/httpoison/issues/453 is fixed + # Workaround for HTTPoison.MaybeRedirect not always providing redirect_url def maybe_follow_redirect( {:ok, %HTTPoison.MaybeRedirect{headers: headers}} = resp, parse_fn, diff --git a/mix.exs b/mix.exs index 8aeb0b8..7993825 100644 --- a/mix.exs +++ b/mix.exs @@ -5,8 +5,8 @@ defmodule TusClient.MixProject do def project do [ app: :tus_client, - version: "0.1.1", - elixir: "~> 1.10", + version: "0.2.0", + elixir: "~> 1.14", start_permanent: Mix.env() == :prod, deps: deps(), test_coverage: [tool: ExCoveralls], @@ -29,12 +29,12 @@ defmodule TusClient.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:httpoison, "~> 1.8"}, + {:httpoison, "~> 2.0"}, # development stuff, - {:bypass, "~> 2.1.0-rc.0", only: :test}, - {:excoveralls, "~> 0.13", only: :test, runtime: false}, - {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, - {:credo, "~> 1.5", only: [:dev, :test], runtime: false} + {:bypass, "~> 2.1", only: :test}, + {:excoveralls, "~> 0.18", only: :test, runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false} ] end end diff --git a/mix.lock b/mix.lock index 1b87ad0..a384930 100644 --- a/mix.lock +++ b/mix.lock @@ -1,31 +1,28 @@ %{ - "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, - "bypass": {:hex, :bypass, "2.1.0-rc.0", "3581b77401b1e34e90a355c3946dca7b154666966f945a1118c221ea8fb7bc0a", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "61f04e917f2556893eb6d44e1d9506063a8104b307a1055a702ca08f94e04332"}, - "certifi": {:hex, :certifi, "2.8.0", "d4fb0a6bb20b7c9c3643e22507e42f356ac090a1dcea9ab99e27e0376d695eba", [:rebar3], [], "hexpm", "6ac7efc1c6f8600b08d625292d4bbf584e14847ce1b6b5c44d983d273e1097ea"}, - "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"}, - "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"}, - "cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"}, - "credo": {:hex, :credo, "1.5.1", "4fe303cc828412b9d21eed4eab60914c401e71f117f40243266aafb66f30d036", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "0b219ca4dcc89e4e7bc6ae7e6539c313e738e192e10b85275fa1e82b5203ecd7"}, - "dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"}, - "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, - "excoveralls": {:hex, :excoveralls, "0.13.3", "edc5f69218f84c2bf61b3609a22ddf1cec0fbf7d1ba79e59f4c16d42ea4347ed", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cc26f48d2f68666380b83d8aafda0fffc65dafcc8d8650358e0b61f6a99b1154"}, - "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, - "file_system": {:hex, :file_system, "0.2.9", "545b9c9d502e8bfa71a5315fac2a923bd060fd9acb797fe6595f54b0f975fd32", [:mix], [], "hexpm", "3cf87a377fe1d93043adeec4889feacf594957226b4f19d5897096d6f61345d8"}, - "hackney": {:hex, :hackney, "1.18.0", "c4443d960bb9fba6d01161d01cd81173089686717d9490e5d3606644c48d121f", [:rebar3], [{:certifi, "~>2.8.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "9afcda620704d720db8c6a3123e9848d09c87586dc1c10479c42627b905b5c5e"}, - "httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, - "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, + "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, + "credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"}, + "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, + "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, + "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, + "httpoison": {:hex, :httpoison, "2.3.0", "10eef046405bc44ba77dc5b48957944df8952cc4966364b3cf6aa71dce6de587", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d388ee70be56d31a901e333dbcdab3682d356f651f93cf492ba9f06056436a2c"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "1.4.0", "5066f14944b470286146047d2f73518cf5cca82f8e4815cf35d196b58cf07c47", [:mix], [], "hexpm", "75fa42c4228ea9a23f70f123c74ba7cece6a03b1fd474fe13f6a7a85c6ea4ff6"}, - "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, - "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, - "plug": {:hex, :plug, "1.11.0", "f17217525597628298998bc3baed9f8ea1fa3f1160aa9871aee6df47a6e4d38e", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2d9c633f0499f9dc5c2fd069161af4e2e7756890b81adcbb2ceaa074e8308876"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.4.1", "779ba386c0915027f22e14a48919a9545714f849505fa15af2631a0d298abf0f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d72113b6dff7b37a7d9b2a5b68892808e3a9a752f2bf7e503240945385b70507"}, - "plug_crypto": {:hex, :plug_crypto, "1.2.0", "1cb20793aa63a6c619dd18bb33d7a3aa94818e5fd39ad357051a67f26dfa2df6", [:mix], [], "hexpm", "a48b538ae8bf381ffac344520755f3007cc10bd8e90b240af98ea29b69683fc2"}, - "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, - "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, - "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.8.0", "07789e9c03539ee51bb14a07839cc95aa96999fd8846ebfd28c97f0b50c7b612", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9cbfaaf17463334ca31aed38ea7e08a68ee37cabc077b1e9be6d2fb68e0171d0"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, } diff --git a/test/tus_client/delete_test.exs b/test/tus_client/delete_test.exs new file mode 100644 index 0000000..bbf2435 --- /dev/null +++ b/test/tus_client/delete_test.exs @@ -0,0 +1,93 @@ +defmodule TusClient.DeleteTest do + @moduledoc false + use ExUnit.Case, async: true + import Plug.Conn + + alias TusClient.Delete + + setup do + bypass = Bypass.open() + {:ok, bypass: bypass} + end + + test "request/1 204", %{bypass: bypass} do + Bypass.expect_once(bypass, "DELETE", "/files/foobar", fn conn -> + assert_version(conn) + + conn + |> resp(204, "") + end) + + assert :ok = Delete.request(file_url(bypass.port)) + end + + test "request/1 204 with custom headers", %{bypass: bypass} do + Bypass.expect_once(bypass, "DELETE", "/files/foobar", fn conn -> + assert ["bar"] = get_req_header(conn, "foo") + assert_version(conn) + + conn + |> resp(204, "") + end) + + assert :ok = Delete.request(file_url(bypass.port), [{"foo", "bar"}]) + end + + test "request/1 404", %{bypass: bypass} do + Bypass.expect_once(bypass, "DELETE", "/files/foobar", fn conn -> + conn + |> resp(404, "") + end) + + assert {:error, :not_found} = Delete.request(file_url(bypass.port)) + end + + test "request/1 410", %{bypass: bypass} do + Bypass.expect_once(bypass, "DELETE", "/files/foobar", fn conn -> + conn + |> resp(410, "") + end) + + assert {:error, :not_found} = Delete.request(file_url(bypass.port)) + end + + test "request/1 500", %{bypass: bypass} do + Bypass.expect_once(bypass, "DELETE", "/files/foobar", fn conn -> + conn + |> resp(500, "") + end) + + assert {:error, :generic} = Delete.request(file_url(bypass.port)) + end + + test "request/1 transport error", %{bypass: _bypass} do + assert {:error, :transport} = Delete.request(file_url(0)) + end + + test "request/1 302 redirect", %{bypass: bypass} do + Bypass.expect_once(bypass, "DELETE", "/files/foobar", fn conn -> + conn + |> put_resp_header( + "location", + "http://localhost:#{bypass.port}/somewhere/foobar" + ) + |> resp(302, "") + end) + + Bypass.expect_once(bypass, "DELETE", "/somewhere/foobar", fn conn -> + assert_version(conn) + + conn + |> resp(204, "") + end) + + assert :ok = Delete.request(file_url(bypass.port), [], follow_redirect: true) + end + + defp file_url(port), do: "http://localhost:#{port}/files/foobar" + + defp assert_version(conn) do + assert get_req_header(conn, "tus-resumable") == ["1.0.0"] + conn + end +end diff --git a/test/tus_client/head_test.exs b/test/tus_client/head_test.exs index 79cb8b4..d53d3ee 100644 --- a/test/tus_client/head_test.exs +++ b/test/tus_client/head_test.exs @@ -1,7 +1,7 @@ defmodule TusClient.HeadTest do @moduledoc false use ExUnit.Case, async: true - use Plug.Test + import Plug.Conn alias TusClient.Head diff --git a/test/tus_client/options_test.exs b/test/tus_client/options_test.exs index 8cfc30f..1a72f33 100644 --- a/test/tus_client/options_test.exs +++ b/test/tus_client/options_test.exs @@ -1,7 +1,7 @@ defmodule TusClient.OptionsTest do @moduledoc false use ExUnit.Case, async: true - use Plug.Test + import Plug.Conn alias TusClient.Options @@ -99,9 +99,7 @@ defmodule TusClient.OptionsTest do end) assert {:ok, %{max_size: 1234, extensions: ["creation", "expiration"]}} = - Options.request(endpoint_url(bypass.port), [], - follow_redirect: true - ) + Options.request(endpoint_url(bypass.port), [], follow_redirect: true) end defp endpoint_url(port), do: "http://localhost:#{port}/files" diff --git a/test/tus_client/patch_test.exs b/test/tus_client/patch_test.exs index 6ef286a..37fe84e 100644 --- a/test/tus_client/patch_test.exs +++ b/test/tus_client/patch_test.exs @@ -1,7 +1,7 @@ defmodule TusClient.PatchTest do @moduledoc false use ExUnit.Case, async: true - use Plug.Test + import Plug.Conn alias TusClient.Patch diff --git a/test/tus_client/post_test.exs b/test/tus_client/post_test.exs index 74b2bad..2b97d85 100644 --- a/test/tus_client/post_test.exs +++ b/test/tus_client/post_test.exs @@ -1,7 +1,7 @@ defmodule TusClient.PostTest do @moduledoc false use ExUnit.Case, async: true - use Plug.Test + import Plug.Conn alias TusClient.Post @@ -60,9 +60,7 @@ defmodule TusClient.PostTest do end) assert {:ok, %{location: endpoint_url(bypass.port) <> "/foofile"}} == - Post.request(endpoint_url(bypass.port), path, [], - follow_redirect: true - ) + Post.request(endpoint_url(bypass.port), path, [], follow_redirect: true) end test "request/1 with metadata", %{bypass: bypass, tmp_file: path} do diff --git a/test/tus_client_test.exs b/test/tus_client_test.exs index 390a4b8..07239d6 100644 --- a/test/tus_client_test.exs +++ b/test/tus_client_test.exs @@ -1,7 +1,7 @@ defmodule TusClientTest do @moduledoc false use ExUnit.Case, async: false - use Plug.Test + import Plug.Conn alias TusClient @@ -9,8 +9,6 @@ defmodule TusClientTest do bypass = Bypass.open() {:ok, path} = random_file() - # Application.put_env(:tus_client, TusClient, chunk_len: 4) - on_exit(fn -> File.rm!(path) end) @@ -270,6 +268,296 @@ defmodule TusClientTest do TusClient.upload(endpoint_url(bypass.port), path) end + # --- Resume tests --- + + test "resume/3 resumes from server offset", %{bypass: bypass, tmp_file: path} do + fname = random_file_name() + data = "yaddayaddamohmoh" + :ok = File.write!(path, data) + data_size = byte_size(data) + + # HEAD returns offset 4 (first 4 bytes already uploaded) + Bypass.expect_once(bypass, "HEAD", "/files/#{fname}", fn conn -> + conn + |> put_resp_header("upload-offset", "4") + |> put_resp_header("upload-length", to_string(data_size)) + |> put_resp_header("cache-control", "no-store") + |> resp(200, "") + end) + + {:ok, store_path} = random_file() + # Pre-fill store with first 4 bytes to simulate partial upload + File.write!(store_path, "yadd") + + Bypass.expect(bypass, "PATCH", "/files/#{fname}", fn conn -> + {:ok, body, conn} = read_body(conn) + File.write!(store_path, body, [:append]) + + {:ok, %{size: new_offset}} = File.stat(store_path) + + conn + |> put_resp_header("upload-offset", to_string(new_offset)) + |> resp(204, "") + end) + + location = endpoint_url(bypass.port) <> "/#{fname}" + + assert {:ok, ^location} = + TusClient.resume(location, path, chunk_len: 4) + + # Verify the final stored data matches original + assert File.read!(store_path) == "yadd" <> String.slice(data, 4..-1//1) + + File.rm!(store_path) + end + + test "resume/3 returns not_found when upload is gone", %{bypass: bypass, tmp_file: path} do + fname = random_file_name() + data = "yaddayaddamohmoh" + :ok = File.write!(path, data) + + Bypass.expect_once(bypass, "HEAD", "/files/#{fname}", fn conn -> + conn + |> resp(404, "") + end) + + location = endpoint_url(bypass.port) <> "/#{fname}" + + assert {:error, :not_found} = TusClient.resume(location, path) + end + + test "resume/3 returns ok immediately when upload is already complete", %{ + bypass: bypass, + tmp_file: path + } do + fname = random_file_name() + data = "yaddayaddamohmoh" + :ok = File.write!(path, data) + data_size = byte_size(data) + + # HEAD returns offset == file size (upload is already done) + Bypass.expect_once(bypass, "HEAD", "/files/#{fname}", fn conn -> + conn + |> put_resp_header("upload-offset", to_string(data_size)) + |> put_resp_header("upload-length", to_string(data_size)) + |> put_resp_header("cache-control", "no-store") + |> resp(200, "") + end) + + location = endpoint_url(bypass.port) <> "/#{fname}" + + # Should not attempt any PATCH — just return ok + assert {:ok, ^location} = TusClient.resume(location, path) + end + + # --- Cancel tests --- + + test "cancel/2 sends DELETE", %{bypass: bypass, tmp_file: _path} do + fname = random_file_name() + + Bypass.expect_once(bypass, "DELETE", "/files/#{fname}", fn conn -> + conn + |> resp(204, "") + end) + + location = endpoint_url(bypass.port) <> "/#{fname}" + assert :ok = TusClient.cancel(location) + end + + test "cancel/2 returns not_found", %{bypass: bypass, tmp_file: _path} do + fname = random_file_name() + + Bypass.expect_once(bypass, "DELETE", "/files/#{fname}", fn conn -> + conn + |> resp(404, "") + end) + + location = endpoint_url(bypass.port) <> "/#{fname}" + assert {:error, :not_found} = TusClient.cancel(location) + end + + # --- Callback tests --- + + test "upload/3 calls on_progress callback", %{bypass: bypass, tmp_file: path} do + fname = random_file_name() + data = "yaddayaddamohmoh" + :ok = File.write!(path, data) + data_size = byte_size(data) + + test_pid = self() + + Bypass.expect_once(bypass, "OPTIONS", "/files", fn conn -> + conn + |> put_resp_header("tus-version", "1.0.0") + |> put_resp_header("tus-max-size", "1234") + |> put_resp_header("tus-extension", "creation,expiration") + |> resp(200, "") + end) + + Bypass.expect_once(bypass, "POST", "/files", fn conn -> + conn + |> put_resp_header("location", endpoint_url(bypass.port) <> "/#{fname}") + |> resp(201, "") + end) + + {:ok, store_path} = random_file() + + Bypass.expect(bypass, "PATCH", "/files/#{fname}", fn conn -> + {:ok, body, conn} = read_body(conn) + File.write!(store_path, body, [:append]) + + {:ok, %{size: new_offset}} = File.stat(store_path) + + conn + |> put_resp_header("upload-offset", to_string(new_offset)) + |> resp(204, "") + end) + + File.rm!(store_path) + + on_progress = fn offset, total -> + send(test_pid, {:progress, offset, total}) + end + + assert {:ok, _location} = + TusClient.upload(endpoint_url(bypass.port), path, + chunk_len: 4, + on_progress: on_progress + ) + + # Should have received the initial progress (0) + multiple chunk progress messages + assert_received {:progress, 0, ^data_size} + # Final progress should equal total + assert_received {:progress, ^data_size, ^data_size} + end + + test "upload/3 calls on_complete callback", %{bypass: bypass, tmp_file: path} do + fname = random_file_name() + data = "yaddayaddamohmoh" + :ok = File.write!(path, data) + + test_pid = self() + + Bypass.expect_once(bypass, "OPTIONS", "/files", fn conn -> + conn + |> put_resp_header("tus-version", "1.0.0") + |> put_resp_header("tus-max-size", "1234") + |> put_resp_header("tus-extension", "creation,expiration") + |> resp(200, "") + end) + + location = endpoint_url(bypass.port) <> "/#{fname}" + + Bypass.expect_once(bypass, "POST", "/files", fn conn -> + conn + |> put_resp_header("location", location) + |> resp(201, "") + end) + + {:ok, store_path} = random_file() + + Bypass.expect(bypass, "PATCH", "/files/#{fname}", fn conn -> + {:ok, body, conn} = read_body(conn) + File.write!(store_path, body, [:append]) + + {:ok, %{size: new_offset}} = File.stat(store_path) + + conn + |> put_resp_header("upload-offset", to_string(new_offset)) + |> resp(204, "") + end) + + File.rm!(store_path) + + on_complete = fn loc -> + send(test_pid, {:complete, loc}) + end + + assert {:ok, ^location} = + TusClient.upload(endpoint_url(bypass.port), path, + chunk_len: 4, + on_complete: on_complete + ) + + assert_received {:complete, ^location} + end + + test "upload/3 calls on_error callback before retry", %{bypass: bypass, tmp_file: path} do + fname = random_file_name() + data = "yaddayaddamohmoh" + :ok = File.write!(path, data) + + test_pid = self() + {:ok, agent} = Agent.start_link(fn -> 0 end) + {:ok, store_path} = random_file() + + Bypass.expect_once(bypass, "OPTIONS", "/files", fn conn -> + conn + |> put_resp_header("tus-version", "1.0.0") + |> put_resp_header("tus-max-size", "1234") + |> put_resp_header("tus-extension", "creation,expiration") + |> resp(200, "") + end) + + Bypass.expect_once(bypass, "POST", "/files", fn conn -> + conn + |> put_resp_header("location", endpoint_url(bypass.port) <> "/#{fname}") + |> resp(201, "") + end) + + Bypass.expect(bypass, "PATCH", "/files/#{fname}", fn conn -> + case Agent.get(agent, fn n -> n end) do + 0 -> + # First PATCH fails + Agent.update(agent, fn n -> n + 1 end) + conn |> resp(500, "") + + _ -> + # Subsequent succeed + Agent.update(agent, fn n -> n + 1 end) + {:ok, body, conn} = read_body(conn) + File.write!(store_path, body, [:append]) + + {:ok, %{size: new_offset}} = File.stat(store_path) + + conn + |> put_resp_header("upload-offset", to_string(new_offset)) + |> resp(204, "") + end + end) + + File.rm!(store_path) + + on_error = fn reason, retry_nr -> + send(test_pid, {:error_callback, reason, retry_nr}) + end + + assert {:ok, _location} = + TusClient.upload(endpoint_url(bypass.port), path, on_error: on_error) + + # Should have been called once with retry_nr 1 (first retry attempt) + assert_received {:error_callback, :generic, 1} + end + + # --- Max size pre-check tests --- + + test "upload/3 rejects file exceeding server max_size", %{bypass: bypass, tmp_file: path} do + # Write a file larger than the server's max_size + data = String.duplicate("x", 100) + :ok = File.write!(path, data) + + Bypass.expect_once(bypass, "OPTIONS", "/files", fn conn -> + conn + |> put_resp_header("tus-version", "1.0.0") + |> put_resp_header("tus-max-size", "10") + |> put_resp_header("tus-extension", "creation,expiration") + |> resp(200, "") + end) + + # POST should never be called since we fail early + assert {:error, :too_large} = TusClient.upload(endpoint_url(bypass.port), path) + end + defp endpoint_url(port), do: "http://localhost:#{port}/files" defp random_file do