Fil.Adapter behaviour (Fil v0.1.0)

Copy Markdown View Source

The contract every adapter implements.

An adapter is a stateless module. init/1 turns the options into a state term once, when Fil.disk/1 builds the disk, and every other callback gets that state. There's no process to start, supervise or shut down.

Plugins run in Fil before an adapter is called, so an adapter never needs to know about them.

Two rules keep adapters small:

  • Mutations return a bare :ok. Fil wraps results into {:ok, %Fil.Ref{}} and handles cross-disk copies, so adapters don't have to. Errors are Fil's error structs (see Errors), and Fil fills in the operation and the path.
  • Paths arrive normalized and jailed. Fil has already resolved . and .., collapsed repeated / and rejected escapes, and "." means the disk root. Adapters may check again (Fil.Adapter.Local does), but they don't need to parse paths defensively.

Contract

Fil uses the vocabulary of Elixir's File module, but every adapter behaves like an object store, so the semantics differ where the two disagree. Beyond the callback signatures, this is the contract:

SituationFileEvery Fil adapter
write/4 into a missing directory{:error, :enoent}creates the missing parents
exclusive write/4 on an existing file{:error, :eexist}{:error, %Fil.AlreadyExistsError{}}
cp/4 or rename/4 into a missing directory{:error, :enoent}creates the missing parents
rm/3 on a missing file{:error, :enoent}:ok
ls/3 on a missing directory{:error, :enoent}{:ok, []}
ls/3 resultsnames, one level deep{path, stat} pairs, one level deep; only files with recursive: true
rm_rf/3 results{:ok, removed_paths}, directories included{:ok, count} of removed files
rm_rf/3 on "reports"the directory reportsreports and all of reports/, not reports.txt
pathsrelative to the working directory, or absoluterelative to the disk root, never above it

An exclusive write is File.write/3 with [:exclusive], and write/4 with if_exists: :error.

Where an adapter returns :ok, the Fil function returns {:ok, %Fil.Ref{}}, and Fil.ls/2 turns the pairs into refs.

Options that File has no equivalent for:

  • checksum: on write/4 sends a checksum of the content where the storage keeps one, and storage that finds the content doesn't match fails with Fil.ChecksumMismatchError. Storage without checksums ignores the option
  • checksum: on stat/3 fills in Fil.Stat's :checksum, from the storage or computed from the content
  • verify_checksum: true on read/3 fails with Fil.ChecksumMismatchError when the content doesn't match a stored checksum

Fil.AdapterCase (internal for now) tests this contract against a live disk.

Where the adapters differ

Every adapter Fil ships reads, writes, lists, copies and checks checksums, and every disk builds public and signed URLs (S3 itself, the others with Fil.Plugin.URL), so code written against one disk runs on the others. What's left are edge cases:

SituationFil.Adapter.LocalFil.Adapter.S3Fil.Adapter.Memory
directoriesexist on their own, can be emptyprefixes onlyprefixes only
rm/3 on a directoryFil.InvalidRequestError:ok, removes nothing:ok, removes nothing
read/3 or cp/4 of a directoryFil.InvalidRequestErrorFil.NotFoundErrorFil.NotFoundError
write/4 to report.txt/x when report.txt is a fileFil.InvalidRequestErrorwrites bothwrites both
stat/3 with checksum:computed from the contentthe checksum the write stored, or nilsame as S3
verify_checksum: trueignoredcompared with the stored checksumcompared with the stored checksum
:content_type in a statnil (Fil.Plug guesses from the extension)from the writefrom the write
:etag in a statweak, "size-mtime"from S3MD5 of the content
URLswith Fil.Plugin.URL, served by Fil.Plugby S3with the plugin, served by Fil.Plug

Errors

Every error is an exception struct, listed under Errors in the sidebar. Which struct you get tells you what to do next, and it's the same on every adapter: a missing file is a Fil.NotFoundError on the local disk and on S3. Fil.UnavailableError is the only one where retrying can help.

Every error has the same fields:

  • :op: the operation, such as :read (see Fil.Op.name/0)
  • :path: the path as the caller passed it (before plugins rewrote it), relative to the disk root. For a copy or a move, the side that failed
  • :disk: the Fil.Disk. Fil.ref(error.disk, error.path) is a ref to the file, and Fil.Disk.adapter(error.disk) returns the adapter
  • :reason: the exact cause, as the storage reported it, in the form its adapter documents (:enoent from Fil.Adapter.Local, "NoSuchKey" from Fil.Adapter.S3). nil only when a plugin built the error

Fil returns two of them itself: Fil.InvalidRequestError with reason: :ebadpath for a path that escapes the disk root, and Fil.UnsupportedError with reason: :no_callback for a URL on an adapter without that callback.

An adapter returns the struct with :reason set and leaves :op, :path and :disk nil: Fil fills in those that are still nil. An adapter that fails on the destination of a cp/4 or rename/4 sets :path to the destination, because Fil fills in the source. Anything in {:error, _} that isn't an exception raises ArgumentError.

A failure that fits a struct is returned as that struct, whatever the storage reported: Fil.Adapter.Local turns the :eperm that deleting a directory gives into :eisdir, and Fil.Adapter.S3 checks a copy that the server refused with a plain 400 for a missing source. Directories only exist as prefixes on S3 and in memory, so directory errors only come from storage with directories of its own (see Where the adapters differ).

Summary

Callbacks

Copies a file within the same disk.

Validates options and builds the state passed to every other callback.

Lists prefix, one level deep unless recursive: true.

Reads the whole file.

Moves a file within the same disk.

Deletes a file. Idempotent: a missing file is still :ok.

Removes everything under prefix and returns the number of deleted files.

Builds a URL that grants temporary access to a file.

Returns metadata for a file or directory.

Builds the public URL of a file, without a signature.

Writes content, creating parent directories as needed.

Types

error()

@type error() :: {:error, Fil.error()}

opts()

@type opts() :: keyword()

path()

@type path() :: String.t()

state()

@type state() :: term()

Callbacks

cp(state, path, path, opts)

@callback cp(state(), path(), path(), opts()) :: :ok | error()

Copies a file within the same disk.

A copy across two disks doesn't call this: Fil reads the file with read/3 on the source adapter and writes it with write/4 on the destination adapter.

init(opts)

@callback init(opts()) :: {:ok, state()} | {:error, term()}

Validates options and builds the state passed to every other callback.

Not an operation, so an error is anything that explains the bad option, such as a NimbleOptions.ValidationError. Fil.Disk.new/1 raises it as an ArgumentError.

ls(state, path, opts)

@callback ls(state(), path(), opts()) :: {:ok, [{path(), Fil.Stat.t()}]} | error()

Lists prefix, one level deep unless recursive: true.

Returns {path, stat} pairs. Paths are relative to the disk root, and each stat holds what the listing returned. Fil turns the pairs into Fil.Refs; adapters don't build those themselves.

read(state, path, opts)

@callback read(state(), path(), opts()) :: {:ok, binary()} | error()

Reads the whole file.

rename(state, path, path, opts)

@callback rename(state(), path(), path(), opts()) :: :ok | error()

Moves a file within the same disk.

A move across two disks doesn't call this: Fil copies the file as described in cp/4, then removes the source with rm/3.

rm(state, path, opts)

@callback rm(state(), path(), opts()) :: :ok | error()

Deletes a file. Idempotent: a missing file is still :ok.

rm_rf(state, path, opts)

@callback rm_rf(state(), path(), opts()) :: {:ok, non_neg_integer()} | error()

Removes everything under prefix and returns the number of deleted files.

signed_url(state, path, opts)

(optional)
@callback signed_url(state(), path(), opts()) :: {:ok, String.t()} | error()

Builds a URL that grants temporary access to a file.

Optional. Adapters whose storage can't sign URLs leave it out, and Fil.signed_url/2 then returns a Fil.UnsupportedError. Fil.Plugin.URL signs URLs for those disks, and Fil.Plug serves them.

stat(state, path, opts)

@callback stat(state(), path(), opts()) :: {:ok, Fil.Stat.t()} | error()

Returns metadata for a file or directory.

url(state, path, opts)

(optional)
@callback url(state(), path(), opts()) :: {:ok, String.t()} | error()

Builds the public URL of a file, without a signature.

Optional. Adapters whose storage has no URLs leave it out, and Fil.url/2 then returns a Fil.UnsupportedError. Fil.Plugin.URL builds URLs for those disks, and Fil.Plug serves them.

write(state, path, iodata, opts)

@callback write(state(), path(), iodata(), opts()) :: :ok | error()

Writes content, creating parent directories as needed.