From 3de985ccc5f1de646406a6da660f285c43221f32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20M=C3=A4nnchen?= Date: Fri, 26 Jul 2024 13:57:23 +0200 Subject: [PATCH] feat: Add Ash.Type.File (#1337) --- lib/ash/type/file.ex | 123 ++++++++++++++++++++++ lib/ash/type/file/implementation.ex | 38 +++++++ lib/ash/type/file/io.ex | 8 ++ lib/ash/type/file/path.ex | 11 ++ lib/ash/type/file/plug_upload.ex | 17 ++++ lib/ash/type/file/source.ex | 42 ++++++++ lib/ash/type/type.ex | 1 + test/type/file_test.exs | 153 ++++++++++++++++++++++++++++ 8 files changed, 393 insertions(+) create mode 100644 lib/ash/type/file.ex create mode 100644 lib/ash/type/file/implementation.ex create mode 100644 lib/ash/type/file/io.ex create mode 100644 lib/ash/type/file/path.ex create mode 100644 lib/ash/type/file/plug_upload.ex create mode 100644 lib/ash/type/file/source.ex create mode 100644 test/type/file_test.exs diff --git a/lib/ash/type/file.ex b/lib/ash/type/file.ex new file mode 100644 index 00000000..d99befbc --- /dev/null +++ b/lib/ash/type/file.ex @@ -0,0 +1,123 @@ +defmodule Ash.Type.File do + @moduledoc """ + A type that represents a file on the filesystem. + + > #### Persistence {: .warning} + > + > This type does not support persisting via `Ash.DataLayer`. + > + > It is mainly intended to be used in + > [arguments](dsl-ash-resource.html#actions-action-argument). + + ## Valid values to cast + + This type can cast multiple types of values: + + * itself + * `Plug.Upload` + * Any value that implements the `Ash.Type.File.Source` protocol. + """ + + use Ash.Type + + alias Ash.Type.File.Implementation + alias Ash.Type.File.IO, as: IOImplementation + alias Ash.Type.File.Path, as: PathImplementation + alias Ash.Type.File.Source + + @enforce_keys [:source, :implementation] + defstruct [:source, :implementation] + @type t() :: %__MODULE__{source: Implementation.source(), implementation: Implementation.t()} + + @impl Ash.Type + def storage_type(_constraints), do: :error + + @impl Ash.Type + def cast_input(nil, _constraints), do: {:ok, nil} + def cast_input(%__MODULE__{} = file, _constraints), do: {:ok, file} + + def cast_input(source, _constraints) do + with {:ok, implementation} <- Source.implementation(source) do + {:ok, %__MODULE__{source: source, implementation: implementation}} + end + end + + @impl Ash.Type + def cast_stored(_file, _constraints), do: :error + + @impl Ash.Type + def dump_to_native(_file, _constraints), do: :error + + @doc """ + Returns the path to the file. + + Not every implementation will support this operation. If the implementation + does not support this operation, then `{:error, :not_supported}` will be + returned. In this case, use the `open/2` function to access the file. + + ## Example + + iex> path = "README.md" + ...> file = Ash.Type.File.from_path(path) + ...> Ash.Type.File.path(file) + {:ok, "README.md"} + + """ + @spec path(file :: t()) :: {:ok, Path.t()} | {:error, :not_supported | Implementation.error()} + def path(%__MODULE__{implementation: implementation, source: source}) do + Code.ensure_loaded!(implementation) + + if function_exported?(implementation, :path, 1) do + implementation.path(source) + else + {:error, :not_supported} + end + end + + @doc """ + Open the file with the given `modes`. + + This function will delegate to the `open/2` function on the `implementation`. + + For details on the `modes` argument, see the `File.open/2` documentation. + + ## Example + + iex> path = "README.md" + ...> file = Ash.Type.File.from_path(path) + ...> Ash.Type.File.open(file, [:read]) + ...> # => {:ok, #PID<0.109.0>} + + """ + @spec open(file :: t(), modes: [File.mode()]) :: + {:ok, IO.device()} | {:error, Implementation.error()} + def open(%__MODULE__{implementation: implementation, source: source}, modes \\ []), + do: implementation.open(source, modes) + + @doc """ + Create a file from a path. + + ## Example + + iex> path = "README.md" + ...> Ash.Type.File.from_path(path) + %Ash.Type.File{source: "README.md", implementation: Ash.Type.File.Path} + + """ + @spec from_path(path :: Path.t()) :: t() + def from_path(path), do: %__MODULE__{source: path, implementation: PathImplementation} + + @doc """ + Create a file from an `IO.device()` + + ## Example + + iex> path = "README.md" + ...> {:ok, device} = File.open(path) + ...> Ash.Type.File.from_io(device) + %Ash.Type.File{source: device, implementation: Ash.Type.File.IO} + + """ + @spec from_io(device :: IO.device()) :: t() + def from_io(device), do: %__MODULE__{source: device, implementation: IOImplementation} +end diff --git a/lib/ash/type/file/implementation.ex b/lib/ash/type/file/implementation.ex new file mode 100644 index 00000000..1041a87c --- /dev/null +++ b/lib/ash/type/file/implementation.ex @@ -0,0 +1,38 @@ +defmodule Ash.Type.File.Implementation do + @moduledoc """ + Behaviour for file implementations that are compatible with `Ash.Type.File`. + """ + + @typedoc "Any `module()` implementing the `Ash.Type.File.Implementation` behaviour." + @type t() :: module() + + @typedoc "The source of the file the implementation operates on." + @type source() :: term() + + @typedoc "Errors returned by the implementation." + @type error() :: term() + + @doc """ + Return path of the file on disk. + + See: `Ash.Type.File.path/1` + + This callback is optional. If the file is not stored on disk, this callback + can be omitted. + """ + @callback path(file :: source()) :: {:ok, Path.t()} | {:error, error()} + + @doc """ + Open `IO.device()` for the file. + + See `Ash.Type.File.open/2` + + The return pid must point to a process following the + [Erlang I/O Protocol](https://www.erlang.org/doc/apps/stdlib/io_protocol.html) + like `StringIO` or `File`. + """ + @callback open(file :: source(), modes :: [File.mode()]) :: + {:ok, IO.device()} | {:error, error()} + + @optional_callbacks [path: 1] +end diff --git a/lib/ash/type/file/io.ex b/lib/ash/type/file/io.ex new file mode 100644 index 00000000..df7de1cc --- /dev/null +++ b/lib/ash/type/file/io.ex @@ -0,0 +1,8 @@ +defmodule Ash.Type.File.IO do + @moduledoc false + + @behaviour Ash.Type.File.Implementation + + @impl Ash.Type.File.Implementation + def open(device, _modes), do: {:ok, device} +end diff --git a/lib/ash/type/file/path.ex b/lib/ash/type/file/path.ex new file mode 100644 index 00000000..67b7e63e --- /dev/null +++ b/lib/ash/type/file/path.ex @@ -0,0 +1,11 @@ +defmodule Ash.Type.File.Path do + @moduledoc false + + @behaviour Ash.Type.File.Implementation + + @impl Ash.Type.File.Implementation + def path(path), do: {:ok, path} + + @impl Ash.Type.File.Implementation + def open(path, modes), do: File.open(path, modes) +end diff --git a/lib/ash/type/file/plug_upload.ex b/lib/ash/type/file/plug_upload.ex new file mode 100644 index 00000000..55428b6d --- /dev/null +++ b/lib/ash/type/file/plug_upload.ex @@ -0,0 +1,17 @@ +if Code.ensure_loaded?(Plug.Upload) do + defmodule Ash.Type.File.PlugUpload do + @moduledoc false + + @behaviour Ash.Type.File.Implementation + + @impl Ash.Type.File.Implementation + def path(%Plug.Upload{path: path}), do: {:ok, path} + + @impl Ash.Type.File.Implementation + def open(%Plug.Upload{path: path}, options), do: File.open(path, options) + end + + defimpl Ash.Type.File.Source, for: Plug.Upload do + def implementation(%Plug.Upload{}), do: {:ok, Ash.Type.File.PlugUpload} + end +end diff --git a/lib/ash/type/file/source.ex b/lib/ash/type/file/source.ex new file mode 100644 index 00000000..2c8db907 --- /dev/null +++ b/lib/ash/type/file/source.ex @@ -0,0 +1,42 @@ +defprotocol Ash.Type.File.Source do + @moduledoc """ + Protocol for allowing the casting of something into an `Ash.Type.File`. + + ## Usage + + ```elixir + defmodule MyStruct do + defstruct [:path] + + @behavior Ash.Type.File.Implementation + + @impl Ash.Type.File.Implementation + def path(%__MODULE__{path: path}), do: {:ok, path} + + @impl Ash.Type.File.Implementation + def open(%__MODULE__{path: path}, modes), do: File.open(path, modes) + + defimpl Ash.Type.File.Source do + def implementation(%MyStruct{} = struct), do: {:ok, MyStruct} + end + end + ``` + """ + + @fallback_to_any true + + alias Ash.Type.File.Implementation + + @doc """ + Detect Implementation of the file. + + Returns an `:ok` tuple with the implementation module if the file is supported + and `:error` otherwise. + """ + @spec implementation(t()) :: {:ok, Implementation.t()} | :error + def implementation(file) +end + +defimpl Ash.Type.File.Source, for: Any do + def implementation(_file), do: :error +end diff --git a/lib/ash/type/type.ex b/lib/ash/type/type.ex index 75356dfe..f394d6fd 100644 --- a/lib/ash/type/type.ex +++ b/lib/ash/type/type.ex @@ -36,6 +36,7 @@ defmodule Ash.Type do atom: Ash.Type.Atom, string: Ash.Type.String, integer: Ash.Type.Integer, + file: Ash.Type.File, float: Ash.Type.Float, duration_name: Ash.Type.DurationName, function: Ash.Type.Function, diff --git a/test/type/file_test.exs b/test/type/file_test.exs new file mode 100644 index 00000000..68c0807b --- /dev/null +++ b/test/type/file_test.exs @@ -0,0 +1,153 @@ +defmodule Ash.Type.FileTest do + use ExUnit.Case + + alias Ash.Test.Domain, as: Domain + alias Ash.Type.File, as: FileType + + doctest FileType + + defmodule MyFile do + defstruct [:path] + + @behaviour FileType.Implementation + + @impl FileType.Implementation + def path(%MyFile{path: path}), do: {:ok, path} + + @impl FileType.Implementation + def open(%MyFile{path: path}, options), do: File.open(path, options) + + defimpl FileType.Source do + def implementation(%MyFile{}), do: {:ok, MyFile} + end + end + + defmodule StringImpl do + @behaviour FileType.Implementation + + @impl FileType.Implementation + def open(string, _options), do: StringIO.open(string, []) + end + + defmodule Post do + @moduledoc false + use Ash.Resource, domain: Domain, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end + + actions do + defaults [] + end + + attributes do + uuid_primary_key :id + end + + actions do + action :import, :file do + argument :file, :file, allow_nil?: false + + run fn %Ash.ActionInput{arguments: %{file: file}}, _context -> + {:ok, file} + end + end + end + end + + describe inspect(&FileType.cast_input/2) do + test "can cast itself" do + file = %FileType{source: __ENV__.file, implementation: FileType.Path} + + assert {:ok, ^file} = + Post + |> Ash.ActionInput.for_action(:import, %{file: file}) + |> Ash.run_action() + end + + test "can cast protocol implementation" do + file = %MyFile{path: __ENV__.file} + + assert {:ok, %FileType{implementation: MyFile, source: ^file}} = + Post + |> Ash.ActionInput.for_action(:import, %{file: file}) + |> Ash.run_action() + end + + test "can cast Plug.Upload" do + file = %Plug.Upload{path: __ENV__.file} + + assert {:ok, %FileType{implementation: FileType.PlugUpload, source: ^file}} = + Post + |> Ash.ActionInput.for_action(:import, %{file: file}) + |> Ash.run_action() + end + + test "can't cast any other value" do + file = DateTime.utc_now() + + assert {:error, + %Ash.Error.Invalid{ + errors: [%Ash.Error.Action.InvalidArgument{field: :file, class: :invalid}] + }} = + Post + |> Ash.ActionInput.for_action(:import, %{file: file}) + |> Ash.run_action() + end + end + + describe inspect(&FileType.open/2) do + test "can open path" do + file = FileType.from_path(__ENV__.file) + + assert {:ok, handle} = FileType.open(file, [:read]) + + assert IO.read(handle, 9) == "defmodule" + end + + test "can open IO device" do + {:ok, device} = StringIO.open("Test") + file = FileType.from_io(device) + + assert {:ok, handle} = FileType.open(file, [:read]) + + assert IO.read(handle, 4) == "Test" + end + + test "can open Plug.Upload" do + file = %FileType{ + source: %Plug.Upload{path: __ENV__.file}, + implementation: FileType.PlugUpload + } + + assert {:ok, handle} = FileType.open(file, [:read]) + + assert IO.read(handle, 9) == "defmodule" + end + end + + describe inspect(&FileType.path/1) do + test "results in path if supported" do + file = FileType.from_path(__ENV__.file) + + assert FileType.path(file) == {:ok, __ENV__.file} + end + + test "results in path for Plug.Upload" do + file = %FileType{ + source: %Plug.Upload{path: __ENV__.file}, + implementation: FileType.PlugUpload + } + + assert FileType.path(file) == {:ok, __ENV__.file} + end + + test "errors if not supported" do + {:ok, device} = StringIO.open("Test") + file = FileType.from_io(device) + + assert FileType.path(file) == {:error, :not_supported} + end + end +end