toml v0.5.2 Toml

TOML for Elixir

Master Hex.pm Version

This is a TOML library for Elixir projects. It is compliant with version 0.5.0 of the official TOML specification. You can find a brief overview of the feature set below, but you are encouraged to read the full spec at the link above (it is short and easy to read!).

Features

  • Decode from string, file, or stream
  • Fully compliant with the latest version of the TOML spec
  • Is tested against toml-test, a test suite for spec-compliant TOML encoders/decoders, used by implementations in multiple languages. The test suite has been integrated into this project to be run under Mix so that we get better error information and so it can run as part of the test suite.
  • Decoder produces a map with values using the appropriate Elixir data types for representation
  • Supports extension via value transformers (see Toml.Transform docs for details)
  • Supports use as a configuration provider in Distillery 2.x+ (use TOML files for configuration!)
  • Decoder is written by hand to take advantage of various optimizations.
  • Library passes Dialyzer checks

Comparison To Other Libraries

I compared toml to four other libraries:

  • toml_elixir
  • tomlex
  • jerry
  • etoml

Of these four, none correctly implement the 0.5.0 specification. Either they are targeting older versions of the spec (in etoml, it is built against pre-0.1), are not fully implemented (i.e. don’t support all features), or have bugs which prevent them from properly parsing a 0.5.0 example file (the test/fixtures/example.toml file in this repository).

If you are looking for a TOML library, at present toml is the only one which full implements the spec and correctly decodes example.toml.

Installation

This library is available on Hex as :toml, and can be added to your deps like so:

def deps do
  [
    {:toml, "~> 0.3"}
  ]
end

NOTE: You can determine the latest version on Hex by running mix hex.info toml.

Type Conversions

In case you are curious how TOML types are translated to Elixir types, the following table provides the conversions.

NOTE: The various possible representations of each type, such as hex/octal/binary integers, quoted/literal strings, etc., are considered to be the same base type (e.g. integer and string respectively in the examples given).

TOMLElixir
StringString.t (binary)
Integerinteger
inf:infinity
+inf:infinity
-inf:negative_infinity
nan:nan
+nan:nan
-nan:negative_nan
Booleanboolean
Offset Date-TimeDateTime.t
Local Date-TimeNaiveDateTime.t
Local DateDate.t
Local TimeTime.t
Arraylist
Tablemap
Table Arraylist(map)

Implementation-specific Behaviors

Certain features of TOML have implementation-specific behavior:

  • -inf, inf, and +inf are all valid infinity values in TOML. In Erlang/Elixir, these don’t have exact representations. Instead, by convention, :infinity is used for positive infinity, as atoms are always larger than integers when using comparison operators, so :infinity > <any integer> will always be true. However, negative infinity cannot be represented, as numbers are always considered smaller than every other type in term comparisons. Instead, we represent it with :negative_infinity, so that the type information is not lost, but you must be careful to deal with it specifically in comparisons/sorting/etc.
  • -nan, nan, and +nan are all valid NaN (not a number) values in TOML. In Erlang/Elixir, NaN is traditionally represented with :nan, but there is no representation for negative NaN, and no API actually produces :nan, instead invalid numbers typically raise errors, in the typical spirit of “let it crash” in the face of errors. For purposes of preserving type information though, we use the :nan convention, and :negative_nan for -NaN. You will need to take care to deal with these values manually if the values need to be preserved.
  • The maximum precision of times in the various time types is microseconds (i.e. precision to six decimal places), if you provide higher precision values (i.e. nanoseconds), the extra precision will be lost.
  • Hex, octal, and binary numbers are converted to integers, so serializing those values after decoding them from a TOML document will be in their decimal representation.

Example Usage

The following is a brief overview of how to use this library. First, let’s take a look at an example TOML file, as borrowed from the TOML homepage:

# This is a TOML document.

title = "TOML Example"

[owner]
name = "Tom Preston-Werner"
dob = 1979-05-27T07:32:00-08:00 # First class dates

[database]
server = "192.168.1.1"
ports = [ 8001, 8001, 8002 ]
connection_max = 5000
enabled = true

[servers]

  # Indentation (tabs and/or spaces) is allowed but not required
  [servers.alpha]
  ip = "10.0.0.1"
  dc = "eqdc10"

  [servers.beta]
  ip = "10.0.0.2"
  dc = "eqdc10"

[clients]
data = [ ["gamma", "delta"], [1, 2] ]

# Line breaks are OK when inside arrays
hosts = [
  "alpha",
  "omega"
]

Parsing

iex> input = """
[database]
server = "192.168.1.1"
"""
...> {:ok, %{"database" => %{"server" => "192.168.1.1"}}} = Toml.decode(input)
...> {:ok, %{database: %{server: "192.168.1.1"}}} = Toml.decode(input, keys: :atoms)
...> stream = File.stream!("example.toml")
...> {:ok, %{"database" => %{"server" => "192.168.1.1"}}} = Toml.decode_stream(stream)
...> {:ok, %{"database" => %{"server" => "192.168.1.1"}}} = Toml.decode_file("example.toml")
...> invalid = """
[invalid]
a = 1 b = 2
"""
...> {:error, {:invalid_toml, reason}} = Toml.decode(invalid); IO.puts(reason)
expected '\n', but got 'b' in nofile on line 2:

    a = 1 b = 2
         ^

:ok

Transforms

Support for extending value conversions is provided by the Toml.Transform behavior. An example is shown below:

Given the follwing TOML document:

[servers.alpha]
ip = "192.168.1.1"
ports = [8080, 8081]

[servers.beta]
ip = "192.168.1.2"
ports = [8082, 8083]

And the following modules:

defmodule Server do
  defstruct [:name, :ip, :ports]
end

defmodule IPStringToCharlist do
  use Toml.Transform
  
  def transform(:ip, v) when is_binary(v) do
    String.to_charlist(v)
  end
  def transform(_k, v), do: v
end

defmodule CharlistToIP do
  use Toml.Transform
  
  def transform(:ip, v) when is_list(v) do
    case :inet.parse_ipv4_address(v) do
      {:ok, address} ->
        address
      {:error, reason} ->
        {:error, {:invalid_ip_address, reason}}
    end
  end
  def transform(:ip, v), do: {:error, {:invalid_ip_address, v}}
  def transform(_k, v), do: v
end

defmodule ServerMapToList do
  use Toml.Transform
  
  def transform(:servers, v) when is_map(v) do
    for {name, server} <- v, do: struct(Server, Map.put(server, :name, name))
  end
  def transform(_k, v), do: v
end

You can convert the TOML document to a more strongly-typed version using the above transforms like so:

iex> transforms = [IPStringToCharlist, CharlistToIP, ServerMapToList]
...> {:ok, result} = Toml.decode("example.toml", keys: :atoms, transforms: transforms)
%{servers: [%Server{name: :alpha, ip: {192,168,1,1}}, ports: [8080, 8081] | _]}

The transforms given here are intended to show how they can be composed: they are applied in the order provided, and the document is transformed using a depth-first, bottom-up traversal. Put another way, you transform the leaves of the tree before the branches; as shown in the example above, this means the :ip key is converted to an address tuple before the :servers key is transformed into a list of Server structs.

Using with Distillery

To use this library as a configuration provider in Distillery, add the following to your rel/config.exs:

release :myapp do
  # ...snip...
  set config_providers: [
    {Toml.Provider, [path: "${XDG_CONFIG_DIR}/myapp.toml", transforms: [...]]}
  ]
end

This will result in Toml.Provider being invoked during boot, at which point it will evaluate the given path and read the TOML file it finds. If one is not found, or is not accessible, the provider will raise an error, and the boot sequence will terminate unsuccessfully. If it succeeds, it persists settings in the file to the application environment (i.e. you access it via Application.get_env/2).

You can pass the same options in the arguments list for Toml.Provider as you can to Toml.decode/2, but :path is required, and :keys only supports :atoms and :atoms! values.

The config provider expects a certain format to the TOML file, namely that keys at the root of the document correspond to applications which need to be configured. If it encounters keys at the root of the document which are not tables, they are ignored.

# This is an example of something that would be ignored
title = "My config file"

# We're expecting something like this:
[myapp]
key = "value"

# To use a bit of Phoenix config, you translate to TOML like so:
[myapp."MyApp.Endpoint"]
cache_static_manifest = "priv/static/cache_manifest.json"

[myapp."MyApp.Endpoint".http]
port = "4000"

[myapp."MyApp.Endpoint".force_ssl]
hsts = true

# Or logger..
[logger]
level = "info"

[logger.console]
format = "[$level] $message \n"

Roadmap

  • [x] Add benchmarking suite
  • [x] Provide options for converting keys to atom, similar to Jason/Poison/etc.
  • [ ] Optimize lexer to always send offsets to decoder, rather than only in some cases
  • [ ] Try to find pathological TOML files to test

License

This project is licensed Apache 2.0, see the LICENSE file in this repo for details.

Link to this section Summary

Functions

Same as decode/1, but returns the document directly, or raises Toml.Error if it fails

Decode the given binary as TOML content

Same as decode_file/1, but returns the document directly, or raises Toml.Error if it fails

Decode the file at the given path as TOML

Same as decode_stream/1, but returns the document directly, or raises Toml.Error if it fails

Decode the given stream as TOML

Link to this section Types

Link to this type error()
error() :: {:error, reason()}
Link to this type key()
key() :: binary() | atom() | term()
Link to this type opt()
opt() ::
  {:keys, :atoms | :atoms! | :string | (key() -> term())}
  | {:filename, String.t()}
  | {:transforms, [Toml.Transform.t()]}
Link to this type opts()
opts() :: [opt()]
Link to this type reason()
reason() :: {:invalid_toml, binary()} | binary()

Link to this section Functions

Link to this function decode!(bin, opts \\ [])
decode!(binary(), opts()) :: map() | no_return()

Same as decode/1, but returns the document directly, or raises Toml.Error if it fails.

Link to this function decode(bin, opts \\ [])
decode(binary(), opts()) :: {:ok, map()} | error()

Decode the given binary as TOML content

Options

You can pass the following options to configure the decoder behavior:

  • :filename - pass a filename to use in error messages

  • :keys - controls how keys in the document are decoded. Possible values are:

    • :strings (default) - decodes keys as strings
    • :atoms - converts keys to atoms with String.to_atom/1
    • :atoms! - converts keys to atoms with String.to_existing_atom/1
    • (key -> term) - converts keys using the provided function
  • :transforms - a list of custom transformations to apply to decoded TOML values, see Toml.Transform.transform/2 for details.

Decoding keys to atoms

The :atoms option uses the String.to_atom/1 call that can create atoms at runtime. Since the atoms are not garbage collected, this can pose a DoS attack vector when used on user-controlled data. It is recommended that if you either avoid converting to atoms, by using keys: :strings, or require known keys, by using the keys: :atoms! option, which will cause decoding to fail if the key is not an atom already in the atom table.

Transformations

You should rarely need custom datatype transformations, but in some cases it can be quite useful. In particular if you want to transform things like IP addresses from their string form to the Erlang address tuples used in most :inet APIs, a custom transform can ensure that all addresses are usable right away, and that validation of those addresses is done as part of decoding the document.

Keep in mind that transforms add additional work to decoding, which may result in reduced performance, if you don’t need the convenience, or the validation, deferring such conversions until the values are used may be a better approach, rather than incurring the overhead during decoding.

Link to this function decode_file!(path, opts \\ [])
decode_file!(binary(), opts()) :: map() | no_return()

Same as decode_file/1, but returns the document directly, or raises Toml.Error if it fails.

Link to this function decode_file(path, opts \\ [])
decode_file(binary(), opts()) :: {:ok, map()} | error()

Decode the file at the given path as TOML

Takes same options as decode/2

Link to this function decode_stream!(stream, opts \\ [])
decode_stream!(Enumerable.t(), opts()) :: map() | no_return()

Same as decode_stream/1, but returns the document directly, or raises Toml.Error if it fails.

Link to this function decode_stream(stream, opts \\ [])
decode_stream(Enumerable.t(), opts()) :: {:ok, map()} | error()

Decode the given stream as TOML.

Takes same options as decode/2