Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 121 additions & 6 deletions lib/tus_client.ex
Original file line number Diff line number Diff line change
@@ -1,50 +1,131 @@
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

@type upload_error ::
: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}

_ ->
Expand All @@ -61,14 +142,25 @@ 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)
|> do_patch(location, path, opts, retry_nr + 1, offset)
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
Expand All @@ -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
35 changes: 35 additions & 0 deletions lib/tus_client/delete.ex
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion lib/tus_client/options.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 3 additions & 5 deletions lib/tus_client/post.ex
Original file line number Diff line number Diff line change
Expand Up @@ -103,20 +103,18 @@ defmodule TusClient.Post do
true

false ->
Logger.warn("Discarding invalid key #{k}")
Logger.warning("Discarding invalid key #{k}")
false
end
end)
|> Map.new()
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
Expand Down
2 changes: 1 addition & 1 deletion lib/tus_client/utils.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 7 additions & 7 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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
Loading