mirror of
https://github.com/ash-project/ash.git
synced 2024-09-19 13:03:02 +12:00
feat: Add Ash.Type.File (#1337)
This commit is contained in:
parent
ea5eb5f552
commit
3de985ccc5
8 changed files with 393 additions and 0 deletions
123
lib/ash/type/file.ex
Normal file
123
lib/ash/type/file.ex
Normal file
|
@ -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
|
38
lib/ash/type/file/implementation.ex
Normal file
38
lib/ash/type/file/implementation.ex
Normal file
|
@ -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
|
8
lib/ash/type/file/io.ex
Normal file
8
lib/ash/type/file/io.ex
Normal file
|
@ -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
|
11
lib/ash/type/file/path.ex
Normal file
11
lib/ash/type/file/path.ex
Normal file
|
@ -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
|
17
lib/ash/type/file/plug_upload.ex
Normal file
17
lib/ash/type/file/plug_upload.ex
Normal file
|
@ -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
|
42
lib/ash/type/file/source.ex
Normal file
42
lib/ash/type/file/source.ex
Normal file
|
@ -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
|
|
@ -36,6 +36,7 @@ defmodule Ash.Type do
|
||||||
atom: Ash.Type.Atom,
|
atom: Ash.Type.Atom,
|
||||||
string: Ash.Type.String,
|
string: Ash.Type.String,
|
||||||
integer: Ash.Type.Integer,
|
integer: Ash.Type.Integer,
|
||||||
|
file: Ash.Type.File,
|
||||||
float: Ash.Type.Float,
|
float: Ash.Type.Float,
|
||||||
duration_name: Ash.Type.DurationName,
|
duration_name: Ash.Type.DurationName,
|
||||||
function: Ash.Type.Function,
|
function: Ash.Type.Function,
|
||||||
|
|
153
test/type/file_test.exs
Normal file
153
test/type/file_test.exs
Normal file
|
@ -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
|
Loading…
Reference in a new issue