├── .formatter.exs
├── .gitignore
├── .tool-versions
├── .travis.yml
├── LICENSE
├── README.md
├── brand
├── logo-text.png
├── logo-text.svg
├── logo-text_25percent.png
├── logo-text_50percent.png
├── logo-thicklines-25percent.png
├── logo-thicklines-50percent.png
├── logo-thicklines.png
├── logo.png
├── logo.svg
├── logo_25percent.png
├── logo_50percent.png
└── logo_thicklines.svg
├── config
└── config.exs
├── lib
├── specify.ex
└── specify
│ ├── options.ex
│ ├── parsers.ex
│ ├── provider.ex
│ └── provider
│ ├── mix_env.ex
│ ├── process.ex
│ └── system_env.ex
├── mix.exs
├── mix.lock
└── test
├── provider
├── mix_env_test.exs
├── process_test.exs
└── system_env_test.exs
├── specify
└── parsers_test.exs
├── specify_test.exs
└── test_helper.exs
/.formatter.exs:
--------------------------------------------------------------------------------
1 | # Used by "mix format"
2 | [
3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
4 | import_deps: [:stream_data]
5 | ]
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where 3rd-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | confy-*.tar
24 |
25 |
--------------------------------------------------------------------------------
/.tool-versions:
--------------------------------------------------------------------------------
1 | elixir 1.10.3
2 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: elixir
2 | elixir:
3 | - 1.7.4
4 | - 1.8.0
5 | after_script:
6 | - MIX_ENV=docs mix deps.get
7 | - MIX_ENV=docs mix inch.report
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Qqwy / Wiebe-Marten
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | `Specify` is a library to create Comfortable, Explicit, Multi-Layered and Well-Documented Specifications for all your configurations, settings and options in Elixir.
4 |
5 | [](https://hex.pm/packages/specify)
6 | [](https://travis-ci.org/Qqwy/elixir_confy)
7 | [](https://hexdocs.pm/specify/index.html)
8 | [](http://inch-ci.org/github/qqwy/elixir_specify)
9 |
10 | ---
11 |
12 | Basic features:
13 |
14 | - Configuration is converted to a struct, with fields being parsed to their appropriate types.
15 | - Specify a stack of sources to fetch the configuration from.
16 | - Always possible to override local configuration using plain arguments to a function call.
17 | - Fail-fast on missing or malformed values.
18 | - Auto-generated documentation based on your config specification.
19 |
20 | Specify can be used both to create normalized configuration structs during runtime and compile-time using both implicit external configuration sources and explicit arguments to a function call.
21 |
22 | ## Installation
23 |
24 | You can install Specify by adding `specify` to your list of dependencies in `mix.exs`:
25 |
26 | ```elixir
27 | def deps do
28 | [
29 | {:specify, "~> 0.7.0"}
30 | ]
31 | end
32 | ```
33 |
34 | Documentation can be found at [https://hexdocs.pm/specify](https://hexdocs.pm/specify).
35 |
36 | ## Examples
37 |
38 |
39 | Basic usage is as follows, using `Specify.defconfig/1`:
40 |
41 |
42 | ```elixir
43 |
44 | defmodule Cosette.CastleOnACloud do
45 | require Specify
46 | Specify.defconfig do
47 | @doc "there are no floors for me to sweep"
48 | field :floors_to_sweep, :integer, default: 0
49 |
50 | @doc "there are a hundred boys and girls"
51 | field :amount_boys_and_girls, :integer, default: 100
52 |
53 | @doc "The lady all in white holds me and sings a lullaby"
54 | field :lullaby, :string
55 |
56 | @doc "Crying is usually not allowed"
57 | field :crying_allowed, :boolean, default: false
58 | end
59 | end
60 | ```
61 |
62 | and later `Specify.load/2`, `Specify.load_explicit/3` (or `YourModule.load/1`, `YourModule.load_explicit/2` which are automatically defined).
63 | ```
64 | iex> Cosette.CastleOnACloud.load(explicit_values: [lullaby: "I love you very much", crying_allowed: true])
65 | %Cosette.CastleOnACloud{
66 | crying_allowed: true,
67 | floors_to_sweep: 0,
68 | lullaby: "I love you very much",
69 | amount_boys_and_girls: 100
70 | }
71 |
72 | ```
73 |
74 | ### Mandatory Fields
75 |
76 | Notice that since the `:lullaby`-field is mandatory, if it is not defined in any of the configuration sources, an error will be thrown:
77 |
78 | ```elixir
79 | Cosette.CastleOnACloud.load
80 | ** (Specify.MissingRequiredFieldsError) Missing required fields for `Elixir.Cosette.CastleOnACloud`: `:lullaby`.
81 | (specify) lib/specify.ex:179: Specify.prevent_missing_required_fields!/3
82 | (specify) lib/specify.ex:147: Specify.load/2
83 | ```
84 |
85 | ### Multiple parsers
86 |
87 | It is possible to specify several parsers for a unique field, using a list of parsers. They
88 | will be tried consecutively in list order. For instance:
89 |
90 | ```elixir
91 | field :some_field, [:string, :boolean], default: true
92 | ```
93 |
94 | ### Loading from Sources
95 |
96 | Loading from another source is easy:
97 |
98 | ```elixir
99 | iex> Application.put_env(Cosette.CastleOnACloud, :lullaby, "sleep little darling")
100 | # or: in a Mix config.ex file
101 | config Cosette.CastleOnACloud, lullaby: "sleep little darling"
102 | ```
103 | ```elixir
104 | iex> Cosette.CastleOnACloud.load(sources: [Specify.Provider.MixEnv])
105 | %Cosette.CastleOnACloud{
106 | crying_allowed: false,
107 | floors_to_sweep: 0,
108 | lullaby: "sleep little darling",
109 | no_boys_and_girls: 100
110 | }
111 | ```
112 |
113 | Rather than passing in the sources when loading the configuration, it often makes more sense to specify them when defining the configuration:
114 |
115 | ```elixir
116 | defmodule Cosette.CastleOnACloud do
117 | require Specify
118 | Specify.defconfig sources: [Specify.Provider.MixEnv] do
119 | # ...
120 | end
121 | end
122 | ```
123 |
124 | ## Providers
125 |
126 | Providers can be specified by passing them to the `sources:` option (while loading the configuration structure or while defining it).
127 | They can also be set globally by altering the `sources:` key of the `Specify` application environment, or per-process using the `:sources` subkey of the `Specify` key in the current process' dictionary (`Process.put_env`).
128 |
129 | Be aware that for bootstrapping reasons, it is impossible to override the `:sources` field globally in an external source (because Specify would not know where to find it).
130 |
131 | `Specify` comes with the following built-in providers:
132 |
133 | - `Specify.Provider.MixEnv`, which uses `Mix.env` / `Application.get_env` to read from the application environment.
134 | - `Specify.Provider.SystemEnv`, which uses `System.get_env` to read from system environment variables.
135 | - `Specify.Provider.Process`, which uses `Process.get` to read from the current process' dictionary.
136 |
137 | Often, Providers have sensible default values on how they work, making their usage simpler:
138 | - `Specify.Provider.Process` will look at the configured `key`, but will default to the configuration specification module name.
139 | - `Specify.Provider.MixEnv` will look at the configured `application_name` and `key`, but will default to the whole environment of an application (`Application.get_all_env`) if no key was set, with `application_name` defaulting to the configuration specification module name.
140 | - `Specify.Provider.SystemEnv` will look at the configured `prefix` but will default to the module name (in all caps), followed by the field name (in all caps, separated by underscores). What names should be used for a field is also configurable.
141 |
142 | ## Writing Providers
143 |
144 | Providers implement the `Specify.Provider` protocol, which consists of only one function: `load/2`.
145 | Its first argument is the implementation's own struct, the second argument being the configuration specification's module name.
146 | If extra information is required about the configuration specification to write a good implementation, the Reflection function `module_name.__specify__` can be used to look these up.
147 |
148 |
149 | ## Roadmap
150 |
151 | - [x] Compound parsers for collections using `{collection_parser, element_parser}`-syntax, with provided `:list` parser.
152 | - [x] Main functionality documentation.
153 | - [x] Parsers documentation.
154 | - [x] Writing basic Tests
155 | - [x] Specify.Parsers
156 | - [x] Main Specify module and functionality.
157 | - [x] Thinking on how to handle environment variable names (capitalization, prefixes).
158 | - [x] Environment Variables (System.get_env) provider
159 | - [x] Specify Provider Tests.
160 | - [ ] Better/more examples
161 | - [ ] Stable release
162 |
163 | ## Possibilities for the future
164 |
165 | - (D)ETS provider
166 | - CLI arguments provider, which could be helpful for defining e.g. Mix tasks.
167 | - .env files provider.
168 | - JSON and YML files provider.
169 | - Nested configs?
170 | - Possibility to load without raising on parsing falure (instead returning a success/failure tuple?)
171 | - Watching for updates and call a configurable handler function when configuration has changed.
172 |
173 | ## Changelog
174 |
175 | - 0.10.0 - Adds an `option({atom, term})` parser that can be used to parse for instance keyword lists. Thank you, @tanguilp!
176 | - 0.9.0 - Allows multi-value parsers by specifying a list of parsers. Thank you, @tanguilp!
177 | - 0.8.0 - Makes string-parsers work on more of Elixir's builtin terms including lists and maps of other types (including lists and maps themselves). Thank you, @tanguilp!
178 | - 0.7.2 - Makes functions clickable in the generated documentation. Thank you, @tanguilp!
179 | - 0.7.1 - Pretty-prints long default values in a multi-line code block in the documentation (#2).
180 | - 0.7 - Adds an `optional` key to the built-in providers. They will only return `{error, :not_found}` if they are not set to optional. Also adds two new ways to indicate sources, which are helpful in environments where you do not have access to the structs directly (such as `Mix.Config` or the newer `Elixir.Config` files.)
181 | - 0.6 - Adds the `mfa` and `function` builtin parsers.
182 | - 0.5 - Adds the `nonnegative_integer`, `positive_integer`, `nonnegative_float`, `positive_float` and `timeout` builtin parsers.
183 | - 0.4.5 - Fixes built-in `integer` and `float` parsers to not crash on input like `"10a"` (but instead return `{:error, _}`).
184 | - 0.4.4 - Fixes references to validation/parsing functions in documentation.
185 | - 0.4.2 - Finishes provider tests; bugfix for the MixEnv provider.
186 | - 0.4.1 - Improves documentation.
187 | - 0.4.0 - Name change: from 'Confy' to 'Specify'. This name has been chosen to be more clear about the intent of the library.
188 | - 0.3.0 - Changed `overrides:` to `explicit_values:` and added `Specify.load_explicit/3` function. (Also added tests and fixed parser bugs).
189 | - 0.2.0 - Initially released version
190 |
191 |
192 | ## Attribution
193 |
194 | I want to thank Chris Keathley for his interesting library [Vapor](https://github.com/keathley/vapor) which helped inspire Specify.
195 |
196 | I also want to thank José Valim for the great conversations we've had about the advantages and disadvantages of various approaches to configuring Elixir applications.
197 |
--------------------------------------------------------------------------------
/brand/logo-text.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Qqwy/elixir-specify/e0cfc89a8d043de73da6729e19ad5d0ca6ebd173/brand/logo-text.png
--------------------------------------------------------------------------------
/brand/logo-text.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
637 |
--------------------------------------------------------------------------------
/brand/logo-text_25percent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Qqwy/elixir-specify/e0cfc89a8d043de73da6729e19ad5d0ca6ebd173/brand/logo-text_25percent.png
--------------------------------------------------------------------------------
/brand/logo-text_50percent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Qqwy/elixir-specify/e0cfc89a8d043de73da6729e19ad5d0ca6ebd173/brand/logo-text_50percent.png
--------------------------------------------------------------------------------
/brand/logo-thicklines-25percent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Qqwy/elixir-specify/e0cfc89a8d043de73da6729e19ad5d0ca6ebd173/brand/logo-thicklines-25percent.png
--------------------------------------------------------------------------------
/brand/logo-thicklines-50percent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Qqwy/elixir-specify/e0cfc89a8d043de73da6729e19ad5d0ca6ebd173/brand/logo-thicklines-50percent.png
--------------------------------------------------------------------------------
/brand/logo-thicklines.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Qqwy/elixir-specify/e0cfc89a8d043de73da6729e19ad5d0ca6ebd173/brand/logo-thicklines.png
--------------------------------------------------------------------------------
/brand/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Qqwy/elixir-specify/e0cfc89a8d043de73da6729e19ad5d0ca6ebd173/brand/logo.png
--------------------------------------------------------------------------------
/brand/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
615 |
--------------------------------------------------------------------------------
/brand/logo_25percent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Qqwy/elixir-specify/e0cfc89a8d043de73da6729e19ad5d0ca6ebd173/brand/logo_25percent.png
--------------------------------------------------------------------------------
/brand/logo_50percent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Qqwy/elixir-specify/e0cfc89a8d043de73da6729e19ad5d0ca6ebd173/brand/logo_50percent.png
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Mix.Config module.
3 | use Mix.Config
4 |
5 | # This configuration is loaded before any dependency and is restricted
6 | # to this project. If another project depends on this project, this
7 | # file won't be loaded nor affect the parent project. For this reason,
8 | # if you want to provide default values for your application for
9 | # 3rd-party users, it should be done in your "mix.exs" file.
10 |
11 | # You can configure your application as:
12 | #
13 | # config :specify, key: :value
14 | #
15 | # and access this configuration in your application as:
16 | #
17 | # Application.get_env(:specify, :key)
18 | #
19 | # You can also configure a 3rd-party app:
20 | #
21 | # config :logger, level: :info
22 | #
23 |
24 | # It is also possible to import configuration files, relative to this
25 | # directory. For example, you can emulate configuration per environment
26 | # by uncommenting the line below and defining dev.exs, test.exs and such.
27 | # Configuration from the imported file will override the ones defined
28 | # here (which is why it is important to import them last).
29 | #
30 | # import_config "#{Mix.env()}.exs"
31 |
--------------------------------------------------------------------------------
/lib/specify.ex:
--------------------------------------------------------------------------------
1 | defmodule Specify do
2 | @moduledoc """
3 | Specify allows you to make your configuration explicit:
4 |
5 | - Specify exactly what fields are expected.
6 | - Specify exactly what values these fields might take, by giving them parser-functions.
7 | - Load their values from a slew of different locations, with 'explicitly passed in to the function' as final option.
8 | """
9 |
10 | defmodule MissingRequiredFieldsError do
11 | @moduledoc """
12 | Default exception to be raised when a required field is not existent in any configuration source.
13 | """
14 | defexception [:message]
15 | end
16 |
17 | defmodule ParsingError do
18 | @moduledoc """
19 | Default exception to be raised when it is impossible to parse one of the configuration values.
20 |
21 | (See also `Specify.Parsers`)
22 | """
23 | defexception [:message]
24 | end
25 |
26 | defmodule Schema do
27 | @moduledoc """
28 | Functions that can be used inside `Specify.defconfig/2`.
29 | """
30 |
31 | @doc """
32 | Specifies a field that is part of the configuration struct.
33 |
34 | Can/should only be called inside a call to `Specify.defconfig`.
35 |
36 | - `name` should be an atom representing the field. It will also become the field name for the struct that is created.
37 | - `parser` should either be:
38 | - an arity-one function capture like `&YourModule.some_type_parser/1`.
39 | - An atom representing one of the common parser function names in `Specify.Parsers` like `:integer`, `:string`, `:boolean` or `:term`.
40 | - A two-element tuple like `{:list, :atom}`. The first element represents the 'collection parser' which is an arity-2 function that takes the 'element parser' as second argument. The second element is the 'element parser'. Both of the elements listed in the tuple can also be either an atom, or a function capture with the correct arity. (like `{&YourAwesomeModule.fancy_collection/2, :integer}`).
41 |
42 | `parser` defaults to `:string`.
43 |
44 | Supported field options are:
45 |
46 | - `default:`, supplies a default value to this field. If not set, the configuration field is set to be _required_.
47 |
48 | You are highly encouraged to add a `@doc`umentation text above each and every field;
49 | these will be added to the configuration's module documentation.
50 | """
51 | defmacro field(name, parser \\ :string, options \\ []) do
52 | quote do
53 | field_documentation = Module.delete_attribute(__MODULE__, :doc)
54 |
55 | field_documentation =
56 | case field_documentation do
57 | {_line, val} ->
58 | val
59 |
60 | nil ->
61 | IO.warn(
62 | "Missing documentation for configuration field `#{unquote(name)}`. Please add it by adding `@doc \"field documentation here\"` above the line where you define it."
63 | )
64 |
65 | ""
66 | end
67 |
68 | Specify.__field__(
69 | __MODULE__,
70 | unquote(name),
71 | unquote(parser),
72 | field_documentation,
73 | unquote(options)
74 | )
75 | end
76 | end
77 | end
78 |
79 | @doc """
80 | Defines a configuration structure in the current module.
81 |
82 | Fields are added to this configuration structure by calling `Specify.Schema.field/3`
83 | (which can be called just as `field` because `Specify.Schema` is autoamatically imported into the
84 | inner context of the call to `defconfig`.)
85 |
86 | The `options` that can be passed to this module are used as defaults for the options passed to a call to `Specify.load/2` or `YourModule.load/1`.
87 |
88 | See also `Specify.Schema.field/3` and `Specify.Options`
89 |
90 | ## Reflection
91 |
92 | The special function `__specify__/1` will be defined on the module as well. It is not intended to be used
93 | by people that want to consume your configuration,
94 | but it is there to e.g. allow `Specify.Provider` implementations to be smarter
95 | in how they fetch the configuration for the module. For instance, configuration
96 | might be lazily fetched, when knowing what field names exist beforehand.
97 |
98 | `YourModule.__specify__/1` supports the following publicly usable parameters:
99 |
100 | - `__specify__(:field_names)` returns a MapSet of atoms, one per field in the configuration structure.
101 | - `__specify__(:defaults)` returns a Map containing only the `field_name => value`s of field names having default values.
102 | - `__specify__(:requireds)` returns a MapSet of atoms, one per required field in the configuration structure.
103 | - `__specify__(:parsers)` returns a Map of the format `field_name => parser`.
104 | - `__specify__(:field_options)` returns a Map of the format `field_name => options`, where `options` is the keyword-list that was passed to the `field` macro.
105 | """
106 | defmacro defconfig(options \\ [], do: block) do
107 | quote do
108 | import Specify.Schema
109 | Module.register_attribute(__MODULE__, :config_fields, accumulate: true)
110 |
111 | try do
112 | unquote(block)
113 | after
114 | config_fields =
115 | Module.get_attribute(__MODULE__, :config_fields)
116 | |> Enum.reverse()
117 |
118 | {line_number, existing_moduledoc} =
119 | Module.delete_attribute(__MODULE__, :moduledoc) || {0, ""}
120 |
121 | Module.put_attribute(
122 | __MODULE__,
123 | :moduledoc,
124 | {line_number, existing_moduledoc <> Specify.__config_doc__(config_fields)}
125 | )
126 |
127 | defstruct(Specify.__struct_fields__(config_fields))
128 |
129 | # Reflection; part of 'public API' for Config Providers,
130 | # but not of public API for consumers of '__MODULE__'.
131 | @field_names Specify.__field_names__(config_fields)
132 | @field_options Specify.__field_options__(config_fields)
133 | @defaults Specify.__defaults__(config_fields)
134 | @required_fields Specify.__required_fields__(config_fields)
135 | @parsers Specify.__parsers__(config_fields)
136 |
137 | # Super secret private reflection; doing this at compile-time speeds up `load`.
138 | @la_defaults for {name, val} <- @defaults, into: %{}, do: {name, [val]}
139 | @la_requireds for name <- @required_fields, into: %{}, do: {name, []}
140 | @loading_accumulator Map.merge(@la_defaults, @la_requireds)
141 |
142 | @doc false
143 | def __specify__(:field_names), do: @field_names
144 | def __specify__(:field_options), do: @field_options
145 | def __specify__(:defaults), do: @defaults
146 | def __specify__(:required_fields), do: @required_fields
147 | def __specify__(:parsers), do: @parsers
148 | def __specify__(:__loading_begin_accumulator__), do: @loading_accumulator
149 |
150 | @doc """
151 | Loads, parses, and normalizes the configuration of `#{inspect(__MODULE__)}`, based on the current source settings, returning the result as a struct.
152 |
153 | For more information about the options this function supports, see
154 | `Specify.load/2` and `Specify.Options`
155 | """
156 | def load(options \\ []), do: Specify.load(__MODULE__, options ++ unquote(options))
157 |
158 | @doc """
159 | Loads, parses and normalizes the configuration of `#{inspect(__MODULE__)}`, using the provided `explicit_values` (and falling back to values configured elsewhere)
160 |
161 | For more information about the options this function supports, see
162 | `Specify.load_explicit/3` and `Specify.Options`
163 | """
164 | def load_explicit(explicit_values, options \\ []),
165 | do: Specify.load_explicit(__MODULE__, explicit_values, options ++ unquote(options))
166 |
167 | :ok
168 | end
169 | end
170 | end
171 |
172 | @doc """
173 | Loads, parses, and normalizes the configuration of `config_module`, based on the current source settings, returning the result as a struct.
174 |
175 | (This is the more general way of calling `config_module.load/1`).
176 |
177 | See `Specify.Options` for more information of the options that can be supplied to this function,
178 | and how it can be configured further.
179 | """
180 | def load(config_module, options \\ []) do
181 | explicit_values =
182 | (options[:explicit_values] || [])
183 | |> Enum.to_list()
184 |
185 | prevent_improper_explicit_values!(config_module, explicit_values)
186 |
187 | options = parse_options(config_module, options)
188 |
189 | # Values explicitly passed in are always the last, highest priority source.
190 | sources = options.sources ++ [explicit_values]
191 | sources_configs = load_sources_configs(config_module, sources)
192 |
193 | if options.explain do
194 | sources_configs
195 | else
196 | prevent_missing_required_fields!(config_module, sources_configs, options)
197 |
198 | parsers = config_module.__specify__(:parsers)
199 |
200 | sources_configs
201 | |> Enum.map(&try_load_and_parse!(&1, parsers, config_module, options))
202 | |> (fn config -> struct(config_module, config) end).()
203 | end
204 | end
205 |
206 | @doc """
207 | Loads, parses and normalizes the configuration of `config_module`, using the provided `explicit_values` (and falling back to values configured elsewhere)
208 |
209 | This call is conceptually the same as `Specify.load(config_module, [explicit_values: [] | options])`, but makes it more explicit that values
210 | are meant to be passed in as arguments.
211 |
212 | Prefer this function if you do not intend to use Specify's 'cascading configuration' functionality, such as when e.g. just parsing options passed to a function,
213 | `use`-statement or other macro.
214 | """
215 | def load_explicit(config_module, explicit_values, options \\ []) do
216 | full_options = put_in(options, [:explicit_values], explicit_values)
217 | load(config_module, full_options)
218 | end
219 |
220 | # Raises if `explicit_values` contains keys that are not part of the configuration structure of `config_module`.
221 | defp prevent_improper_explicit_values!(config_module, explicit_values) do
222 | improper_explicit_values =
223 | explicit_values
224 | |> Keyword.keys()
225 | |> MapSet.new()
226 | |> MapSet.difference(config_module.__specify__(:field_names))
227 |
228 | if(Enum.any?(improper_explicit_values)) do
229 | raise ArgumentError,
230 | "The following fields passed as `:explicit_values` are not part of `#{
231 | inspect(config_module)
232 | }`'s fields: `#{improper_explicit_values |> Enum.map(&inspect/1) |> Enum.join(", ")}`."
233 | end
234 | end
235 |
236 | # Raises appropriate error if required fields of `config_module` are missing in `sources_configs`.
237 | defp prevent_missing_required_fields!(config_module, sources_configs, options) do
238 | missing_required_fields =
239 | sources_configs
240 | |> Enum.filter(fn {_key, value} -> value == [] end)
241 | |> Enum.into(%{})
242 |
243 | if Enum.any?(missing_required_fields) do
244 | field_names = Map.keys(missing_required_fields)
245 |
246 | raise options.missing_fields_error,
247 | "Missing required fields for `#{config_module}`: `#{
248 | field_names |> Enum.map(&inspect/1) |> Enum.join(", ")
249 | }`."
250 | end
251 | end
252 |
253 | # Loads the listed `sources` in turn, warning for missing ones.
254 | defp load_sources_configs(config_module, sources) do
255 | sources
256 | |> Enum.map(&load_source(&1, config_module))
257 | |> reject_and_warn_unloadable_sources(config_module)
258 | |> list_of_configs2config_of_lists(config_module)
259 | end
260 |
261 | # Attempts to parse the highest-priority value of a given `name`.
262 | # Upon failure, raises an appropriate error.
263 | defp try_load_and_parse!({name, values}, parsers, config_module, options) do
264 | parser = construct_parser(parsers[name])
265 |
266 | case parser.(hd(values)) do
267 | {:ok, value} ->
268 | {name, value}
269 |
270 | {:error, reason} ->
271 | raise options.parsing_error,
272 | reason <>
273 | " (required for loading the field `#{inspect(name)}` of `#{inspect(config_module)}`)"
274 |
275 | other ->
276 | raise ArgumentError,
277 | "Improper Specify configuration parser result. Parser `#{inspect(parsers[name])}` is supposed to return either {:ok, val} or {:error, reason} but instead, `#{
278 | inspect(other)
279 | }` was returned."
280 | end
281 | end
282 |
283 | defp construct_parser(parser_list) when is_list(parser_list) do
284 | parser_funs = Enum.map(parser_list, &construct_parser(&1))
285 |
286 | fn thing -> Enum.find_value(
287 | parser_funs,
288 | {:error, "No validating parser found"},
289 | fn parser_fun ->
290 | case parser_fun.(thing) do
291 | {:ok, val} ->
292 | {:ok, val}
293 |
294 | {:error, _} ->
295 | nil
296 | end
297 | end
298 | )
299 | end
300 | end
301 |
302 | defp construct_parser({collection_parser, elem_parser}) do
303 | fn thing -> collection_parser.(thing, elem_parser) end
304 | end
305 |
306 | defp construct_parser(elem_parser) do
307 | elem_parser
308 | end
309 |
310 | # Parses `options` into a normalized `Specify.Options` struct.
311 | defp parse_options(config_module, options)
312 | # Catch bootstrapping-case
313 | defp parse_options(Specify.Options, options) do
314 | %{
315 | __struct__: Specify.Options,
316 | sources:
317 | options[:sources] ||
318 | Process.get(:specify, [])[:sources] ||
319 | Application.get_env(Specify, :sources) ||
320 | [],
321 | missing_fields_error:
322 | options[:missing_fields_error] ||
323 | Process.get(Specify, [])[:missing_fields_error] ||
324 | Application.get_env(Specify, :missing_fields_error) ||
325 | Specify.MissingRequiredFieldsError,
326 | parsing_error:
327 | options[:parsing_error] ||
328 | Process.get(Specify, [])[:parsing_error] ||
329 | Application.get_env(Specify, :parsing_error) ||
330 | Specify.ParsingError,
331 | explain:
332 | options[:explain] ||
333 | false
334 | }
335 | end
336 |
337 | defp parse_options(_config_module, options), do: Specify.Options.load(explicit_values: options)
338 |
339 | # Turns a list of Access-implementations into a map of lists.
340 | # In the end, empty values will look like `key: []`.
341 | # And filled ones like `key: [something | ...]`
342 | defp list_of_configs2config_of_lists(list_of_configs, config_module) do
343 | begin_accumulator = config_module.__specify__(:__loading_begin_accumulator__)
344 |
345 | list_of_configs
346 | |> Enum.reduce(begin_accumulator, fn config, acc ->
347 | :maps.map(
348 | fn key, values_list ->
349 | case Access.fetch(config, key) do
350 | {:ok, val} -> [val | values_list]
351 | :error -> values_list
352 | end
353 | end,
354 | acc
355 | )
356 | end)
357 | end
358 |
359 | defp load_source(source, config_module) do
360 | {source, Specify.Provider.load(source, config_module)}
361 | end
362 |
363 | # Logs errors on sources that cannot be found,
364 | # and transforms `{source, {:ok, config}} -> config` for all successful configurations.
365 | defp reject_and_warn_unloadable_sources(sources_configs, config_module) do
366 | require Logger
367 |
368 | sources_configs
369 | |> Enum.flat_map(fn
370 | {_source, {:ok, config}} ->
371 | [config]
372 |
373 | {source, {:error, error}} ->
374 | case error do
375 | :not_found ->
376 | Logger.warn("""
377 | While loading the configuration `#{inspect(config_module)}`, the source `#{
378 | inspect(source)
379 | }` could not be found.
380 | Please make sure it exists.
381 | In the case you do not need this source, consider removing this source from the `sources:` list.
382 | """)
383 |
384 | :malformed ->
385 | Logger.warn("""
386 | While loading the configuration `#{inspect(config_module)}`, found out that
387 | it was not possible to parse the configuration inside #{inspect(source)}.
388 | This usually indicates a grave problem!
389 | """)
390 | end
391 |
392 | []
393 | end)
394 | end
395 |
396 | @doc false
397 | # Handles the actual work of the `field` macro.
398 | def __field__(module, name, parser, field_documentation, options) do
399 | normalized_parser = normalize_parser(parser)
400 |
401 | Module.put_attribute(module, :config_fields, %{
402 | name: name,
403 | parser: normalized_parser,
404 | original_parser: parser,
405 | documentation: field_documentation,
406 | options: options
407 | })
408 | end
409 |
410 | # Extracts the struct definition keyword list
411 | # from the outputs of the list of `field` calls.
412 | @doc false
413 | def __struct_fields__(config_fields) do
414 | config_fields
415 | |> Enum.map(fn %{name: name, options: options} ->
416 | {name, options[:default]}
417 | end)
418 | end
419 |
420 | @doc false
421 | # Builds the module documentation
422 | # for the configuration.
423 | # This includes information on each of the fields,
424 | # with the user-supplied documentation description,
425 | # as well as the used parser and potential default value.
426 | def __config_doc__(config_fields) do
427 | acc =
428 | config_fields
429 | |> Enum.reduce("", fn %{
430 | name: name,
431 | parser: parser,
432 | original_parser: original_parser,
433 | documentation: documentation,
434 | options: options
435 | },
436 | acc ->
437 | doc = """
438 |
439 | ### #{name}
440 |
441 | #{documentation}
442 |
443 | #{parser_doc(parser, original_parser)}
444 | """
445 |
446 | doc =
447 | case Access.fetch(options, :default) do
448 | {:ok, val} ->
449 | """
450 | #{doc}
451 | Defaults to#{clever_prettyprint(val)}
452 | """
453 |
454 | :error ->
455 | """
456 | #{doc}
457 | Required field.
458 | """
459 | end
460 |
461 | acc <> doc
462 | end)
463 |
464 | """
465 | ## Configuration structure documentation:
466 |
467 | This configuration was made using the `Specify` library.
468 | It contains the following fields:
469 |
470 | #{acc}
471 | """
472 | end
473 |
474 | # Render a multiline Markdown code block if `value` is large enough
475 | # to be pretty-printed across multiple lines.
476 | # otherwise, render an inline Markdown code block.
477 | # Functions are an exception: there are always on their own line to
478 | # make then clickable.
479 | defp clever_prettyprint(f) when is_function(f) do
480 | "&" <> f_str = inspect(f)
481 |
482 | "`#{f_str}`."
483 | end
484 |
485 | defp clever_prettyprint(value) do
486 | inspected = Kernel.inspect(value, printable_limit: :infinity, limit: :infinity, width: 80, pretty: true)
487 | if String.contains?(inspected, "\n") do
488 | """
489 | :
490 | ```
491 | #{inspected}
492 | ```
493 | """
494 | else
495 | " `#{inspected}`."
496 | end
497 | end
498 |
499 | defp parser_doc(parser, original_parser) do
500 | case original_parser do
501 | atom when is_atom(atom) ->
502 | """
503 | Validated/parsed by calling `#{Macro.to_string(parser) |> String.trim_leading("&")}`.
504 |
505 | (Specified as `#{inspect(atom)}`)
506 | """
507 |
508 | {collection_parser, parser} ->
509 | """
510 | Validated/parsed by calling `fn thing -> (#{
511 | Macro.to_string(normalize_parser(collection_parser, 2)) |> String.trim_leading("&")
512 | }).(thing, #{Macro.to_string(normalize_parser(parser))}) end`.
513 |
514 | (Specified as `{#{Macro.to_string(collection_parser)}, #{Macro.to_string(parser)}}`)
515 | """
516 |
517 | _other ->
518 | """
519 | Validated/parsed by calling `#{Macro.to_string(parser) |> String.trim_leading("&")}`.
520 | """
521 | end
522 | end
523 |
524 | @doc false
525 | # Builds a map of fields with default values.
526 | def __defaults__(config_fields) do
527 | config_fields
528 | |> Enum.filter(fn %{options: options} ->
529 | case Access.fetch(options, :default) do
530 | {:ok, _} -> true
531 | :error -> false
532 | end
533 | end)
534 | |> Enum.map(fn %{name: name, options: options} ->
535 | {name, options[:default]}
536 | end)
537 | |> Enum.into(%{})
538 | end
539 |
540 | @doc false
541 | # Builds a MapSet of all the required fields
542 | def __required_fields__(config_fields) do
543 | config_fields
544 | |> Enum.filter(fn %{options: options} ->
545 | case Access.fetch(options, :default) do
546 | :error -> true
547 | _ -> false
548 | end
549 | end)
550 | |> Enum.map(fn %{name: name} ->
551 | name
552 | end)
553 | |> MapSet.new()
554 | end
555 |
556 | @doc false
557 | # Builds a MapSet of all the fields
558 | def __field_names__(config_fields) do
559 | config_fields
560 | |> Enum.map(fn %{name: name} -> name end)
561 | |> MapSet.new()
562 | end
563 |
564 | @doc false
565 | # Builds a MapSet of all the fields
566 | def __field_options__(config_fields) do
567 | config_fields
568 | |> Enum.map(fn %{name: name, options: options} -> {name, options} end)
569 | |> Enum.into(%{})
570 | end
571 |
572 | @doc false
573 | # Builds a map of parsers for the fields.
574 | def __parsers__(config_fields) do
575 | config_fields
576 | |> Enum.map(fn %{name: name, parser: parser} ->
577 | {name, parser}
578 | end)
579 | |> Enum.into(%{})
580 | end
581 |
582 | # Replaces simplified atom parsers with
583 | # an actual reference to the parser function in `Specify.Parsers`.
584 | defp normalize_parser(parser, arity \\ 1)
585 |
586 | defp normalize_parser(parsers, _arity) when is_list(parsers) do
587 | Enum.map(parsers, &normalize_parser(&1))
588 | end
589 |
590 | defp normalize_parser(parser, arity) when is_atom(parser) do
591 | case Specify.Parsers.__info__(:functions)[parser] do
592 | nil ->
593 | raise ArgumentError,
594 | "Parser shorthand `#{inspect(parser)}` was not recognized. Only atoms representing names of functions that live in `Specify.Parsers` are."
595 |
596 | ^arity ->
597 | Function.capture(Specify.Parsers, parser, arity)
598 | end
599 | end
600 |
601 | defp normalize_parser({collection_parser, elem_parser}, _arity) do
602 | {normalize_parser(collection_parser, 2), normalize_parser(elem_parser, 1)}
603 | end
604 |
605 | defp normalize_parser(other, _arity), do: other
606 | end
607 |
--------------------------------------------------------------------------------
/lib/specify/options.ex:
--------------------------------------------------------------------------------
1 | defmodule Specify.Options do
2 | require Specify
3 |
4 | @moduledoc """
5 | This struct represents the options you can pass
6 | to a call of `Specify.load/2` (or `YourModule.load/1`).
7 |
8 | ### Metaconfiguration
9 |
10 | Besides making it nice and explicit to have the options listed here,
11 | `Specify.Options` has itself been defined using `Specify.defconfig/2`,
12 | which means that it (and thus what default options are passed on to to other Specify configurations)
13 | can be configured in the same way.
14 |
15 | """
16 |
17 | @doc false
18 | def list_of_sources(sources) do
19 | res =
20 | Enum.reduce_while(sources, [], fn
21 | source, acc ->
22 | case source do
23 | source = %struct_module{} ->
24 | Protocol.assert_impl!(Specify.Provider, struct_module)
25 | {:cont, [source | acc]}
26 | source when is_atom(source) ->
27 | parse_source_module(source, acc)
28 | {module, args} when is_atom(module) and is_map(args) ->
29 | source = struct(module, args)
30 | Protocol.assert_impl!(Specify.Provider, module)
31 | {:cont, [source | acc]}
32 | {module, fun, args} when is_atom(module) and is_atom(fun) and is_map(args) ->
33 | source = %struct_module{} = Kernel.apply(module, fun, args)
34 | Protocol.assert_impl!(Specify.Provider, struct_module)
35 | {:cont, [source | acc]}
36 | end
37 | end)
38 |
39 | case res do
40 | {:error, error} -> {:error, error}
41 | sources_list -> {:ok, Enum.reverse(sources_list)}
42 | end
43 | end
44 |
45 | defp parse_source_module(module, acc) do
46 | case module.__info__(:functions)[:new] do
47 | 0 ->
48 | source = %struct_module{} = module.new()
49 | Protocol.assert_impl!(Specify.Provider, struct_module)
50 | {:cont, [source | acc]}
51 |
52 | _ ->
53 | {:halt,
54 | {:error,
55 | "`#{inspect(module)}` does not seem to have an appropriate default `new/0` function. Instead, pass a full-fledged struct (like `%#{inspect(module)}{}`), or one use one of the other ways to specify a source. \n\n(See the documentation of `Specify.Options.sources` for more information)"}}
56 | end
57 | end
58 |
59 | Specify.defconfig do
60 | @doc """
61 | A list of structures that implement the `Specify.Provider` protocol, which will be used to fetch configuration from.
62 | Later entries in the list take precedence over earlier entries.
63 | Defaults always have the lowest precedence, and `:explicit_values` always have the highest precedence.
64 |
65 | A source can be:
66 | - A struct. Example: `%Specify.Provider.SystemEnv{}`;
67 | - A module that has a `new/0`-method which returns a struct. Example: `Specify.Provider.SystemEnv`;
68 | - A tuple, whose first argument is a module and second argument is a map of arguments. This will be turned into a full-blown struct at startup using `Kernel.struct/2`. Example: `{Specify.Provider.SystemEnv, %{prefix: "CY", optional: true}}`;
69 | - A {module, function, arguments}-tuple, which will be called on startup. It should return a struct. Example: `{Specify.Provider.SystemEnv, :new, ["CY", [optional: true]]}`.
70 |
71 | In all cases, the struct should implement the `Specify.Provider` protocol (and this is enforced at startup).
72 | """
73 | field(:sources, &Specify.Options.list_of_sources/1, default: [])
74 |
75 | @doc """
76 | A list or map (or other enumerable) representing explicit values
77 | that are to be used instead of what can be found in the implicit sources stack.
78 | """
79 | field(:explicit_values, :term, default: [])
80 |
81 | @doc """
82 | The error to be raised if a missing field which is required has been encountered.
83 | """
84 | field(:missing_fields_error, :term, default: Specify.MissingRequiredFieldsError)
85 |
86 | @doc """
87 | The error to be raised if a field value could not properly be parsed.
88 | """
89 | field(:parsing_error, :term, default: Specify.ParsingError)
90 |
91 | @doc """
92 | When set to `true`, rather than returning the config struct,
93 | a map is returned with every field-key containing a list of consecutive found values.
94 |
95 | This is useful for debugging.
96 | """
97 | field(:explain, :boolean, default: false)
98 | end
99 |
100 | {line_number, existing_moduledoc} = Module.delete_attribute(__MODULE__, :moduledoc) || {0, ""}
101 |
102 | Module.put_attribute(
103 | __MODULE__,
104 | :moduledoc,
105 | {line_number,
106 | existing_moduledoc <>
107 | """
108 | ## Metaconfiguration Gotcha's
109 |
110 | Specify will only be able to find a source after it knows it exists.
111 | This means that it is impossible to define a different set of sources inside an external source.
112 |
113 | For this special case, Specify will look at the current process' Process dictionary,
114 | falling back to the Application environment (also known as the Mix environment),
115 | and finally falling back to an empty list of sources (its default).
116 |
117 | So, from lowest to highest precedence, option values are loaded in this order:
118 |
119 | 1. Specify.Options default
120 | 2. Application Environment `:specify`
121 | 3. Process Dictionary `:specify` field
122 | 4. Options passed to `Specify.defconfig`
123 | 5. Options passed to `YourModule.load`
124 |
125 | Requiring Specify to be configured in such an even more general way seems highly unlikely.
126 | If the current approach does turn out to not be good enough for your use-case,
127 | please open an issue on Specify's issue tracker.
128 | """}
129 | )
130 | end
131 |
--------------------------------------------------------------------------------
/lib/specify/parsers.ex:
--------------------------------------------------------------------------------
1 | defmodule Specify.Parsers do
2 | @moduledoc """
3 | Simple functions to parse strings to datatypes commonly used during configuration.
4 |
5 | These functions can be used as parser/validator function in a call to `Specify.Schema.field`,
6 | by using their shorthand name (`:integer` as shorthand for `&Specify.Parsers.integer/1`).
7 |
8 | (Of course, using their longhand name works as well.)
9 |
10 |
11 | ## Defining your own parser function
12 |
13 | A parser function receives the to-be-parsed/validated value as input,
14 | and should return `{:ok, parsed_val}` on success,
15 | or `{:error, reason}` on failure.
16 |
17 | Be aware that depending on where the configuration is loaded from,
18 | the to-be-parsed value might be a binary string,
19 | or already the Elixir type you want to convert it to.
20 |
21 | """
22 |
23 | @doc """
24 | Parses an integer and turns binary string representing an integer into an integer.
25 | """
26 | def integer(int) when is_integer(int), do: {:ok, int}
27 |
28 | def integer(binary) when is_binary(binary) do
29 | case Integer.parse(binary) do
30 | {int, ""} -> {:ok, int}
31 | {_int, _rest} -> {:error, "the binary `#{binary}` cannot be parsed to an integer."}
32 | :error -> {:error, "the binary `#{binary}` cannot be parsed to an integer."}
33 | end
34 | end
35 |
36 | def integer(other), do: {:error, "#{inspect(other)} is not an integer."}
37 |
38 | @doc """
39 | Similar to integer/1, but only accepts integers larger than 0.
40 | """
41 | def positive_integer(val) do
42 | with {:ok, int} <- integer(val) do
43 | if int > 0 do
44 | {:ok, int}
45 | else
46 | {:error, "integer #{int} is not a positive integer."}
47 | end
48 | end
49 | end
50 |
51 | @doc """
52 | Similar to integer/1, but only accepts integers larger than or equal to 0.
53 | """
54 | def nonnegative_integer(val) do
55 | with {:ok, int} <- integer(val) do
56 | if int >= 0 do
57 | {:ok, int}
58 | else
59 | {:error, "integer #{int} is not a nonnegative integer."}
60 | end
61 | end
62 | end
63 |
64 | @doc """
65 | Parses a float and turns a binary string representing a float into an float.
66 |
67 | Will also accept integers, which are turned into their float equivalent.
68 | """
69 | def float(float) when is_float(float), do: {:ok, float}
70 | def float(int) when is_integer(int), do: {:ok, 1.0 * int}
71 |
72 | def float(binary) when is_binary(binary) do
73 | case Float.parse(binary) do
74 | {float, ""} -> {:ok, float}
75 | {_float, _rest} -> {:error, "the binary `#{binary}` cannot be parserd to a float."}
76 | :error -> {:error, "the binary `#{binary}` cannot be parserd to a float."}
77 | end
78 | end
79 |
80 | def float(other), do: {:error, "`#{inspect(other)}` is not a float"}
81 |
82 | @doc """
83 | Similar to float/1, but only accepts floats larger than 0.
84 | """
85 | def positive_float(val) do
86 | with {:ok, float} <- float(val) do
87 | if float > 0 do
88 | {:ok, float}
89 | else
90 | {:error, "float #{float} is not a positive float."}
91 | end
92 | end
93 | end
94 |
95 | @doc """
96 | Similar to float/1, but only accepts floats larger than or equal to 0.
97 | """
98 | def nonnegative_float(val) do
99 | with {:ok, float} <- float(val) do
100 | if float >= 0 do
101 | {:ok, float}
102 | else
103 | {:error, "float #{float} is not a nonnegative float."}
104 | end
105 | end
106 | end
107 |
108 | @doc """
109 | Parses a binary string and turns anything that implements `String.Chars` into its binary string representation by calling `to_string/1` on it.
110 | """
111 | def string(binary) when is_binary(binary), do: {:ok, binary}
112 |
113 | def string(thing) do
114 | try do
115 | {:ok, to_string(thing)}
116 | rescue
117 | ArgumentError ->
118 | {:error,
119 | "`#{inspect(thing)}` cannot be converted to string because it does not implement the String.Chars protocol."}
120 | end
121 | end
122 |
123 | @doc """
124 | Accepts any Elixir term as-is. Will not do any parsing.
125 |
126 | Only use this as a last resort. It is usually better to create your own dedicated parsing function instead.
127 | """
128 | def term(anything), do: {:ok, anything}
129 |
130 | @doc """
131 | Parses a boolean or a binary string representing a boolean value, turning it into a boolean.
132 | """
133 | def boolean(boolean) when is_boolean(boolean), do: {:ok, boolean}
134 |
135 | def boolean(binary) when is_binary(binary) do
136 | case binary |> Macro.underscore() do
137 | "true" -> {:ok, true}
138 | "false" -> {:ok, false}
139 | _ -> {:error, "`#{binary}` cannot be parsed to a boolean."}
140 | end
141 | end
142 |
143 | def boolean(other), do: {:error, "`#{inspect(other)}` is not a boolean."}
144 |
145 | @doc """
146 | Parses an atom or a binary string representing an (existing) atom.
147 |
148 | Will not create new atoms (See `String.to_existing_atom/1` for more info).
149 | """
150 | def atom(atom) when is_atom(atom), do: {:ok, atom}
151 |
152 | def atom(binary) when is_binary(binary) do
153 | try do
154 | {:ok, String.to_existing_atom(binary)}
155 | rescue
156 | ArgumentError ->
157 | {:error, "`#{binary}` is not an existing atom."}
158 | end
159 | end
160 |
161 | def atom(other), do: {:error, "`#{inspect(other)}` is not an (existing) atom."}
162 |
163 | @doc """
164 | Parses an atom or a binary string representing an (potentially not yet existing!) atom.
165 |
166 | Will create new atoms. Whenever possible, consider using `atom/1` instead.
167 | (See `String.to_atom/1` for more info on why creating new atoms is usually a bad idea).
168 | """
169 | def unsafe_atom(atom) when is_atom(atom), do: {:ok, atom}
170 |
171 | def unsafe_atom(binary) when is_binary(binary) do
172 | {:ok, String.to_atom(binary)}
173 | end
174 |
175 | def unsafe_atom(other), do: {:error, "`#{inspect(other)}` is not convertible to an atom."}
176 |
177 | @doc """
178 | Parses a list of elements.
179 |
180 | In the case a binary string was passed, this parser uses `Code.string_to_quoted` under the hood to check for Elixir syntax, and will only accepts binaries representing lists.
181 |
182 | If a list was passed in (or after turning a binary into a list), it will try to parse each of the elements in turn.
183 | """
184 | def list(list, elem_parser) when is_list(list) do
185 | res_list =
186 | Enum.reduce_while(list, [], fn
187 | elem, acc ->
188 | case elem_parser.(elem) do
189 | {:ok, res} ->
190 | {:cont, [res | acc]}
191 |
192 | {:error, reason} ->
193 | {:halt,
194 | {:error,
195 | "One of the elements of input list `#{inspect(list)}` failed to parse: \n#{reason}."}}
196 | end
197 | end)
198 |
199 | case res_list do
200 | {:error, reason} ->
201 | {:error, reason}
202 |
203 | parsed_list when is_list(parsed_list) ->
204 | {:ok, Enum.reverse(parsed_list)}
205 | end
206 | end
207 |
208 | def list(binary, elem_parser) when is_binary(binary) do
209 | case string_to_term(binary) do
210 | {:ok, list_ast} when is_list(list_ast) ->
211 | list_ast
212 | |> Enum.map(&Macro.expand(&1, __ENV__))
213 | |> list(elem_parser)
214 |
215 | {:ok, _not_a_list} ->
216 | {:error,
217 | "`#{inspect(binary)}`, while parseable as Elixir code, does not represent an Elixir list."}
218 |
219 | {:error, reason} ->
220 | {:error, reason}
221 | end
222 | end
223 |
224 | def list(term, _) do
225 | {:error, "`#{inspect(term)}` does not represent an Elixir list."}
226 | end
227 |
228 | @doc """
229 | Allows to pass in a 'timeout' which is a common setting for OTP-related features,
230 | accepting either a positive integer, or the atom `:infinity`.
231 | """
232 | def timeout(raw) do
233 | case positive_integer(raw) do
234 | {:ok, int} ->
235 | {:ok, int}
236 | {:error, _} ->
237 | case atom(raw) do
238 | {:ok, :infinity} ->
239 | {:ok, :infinity}
240 |
241 | {:ok, _} ->
242 | {:error,
243 | "#{inspect(raw)} is neither a positive integer nor the special atom value `:infinity`"}
244 |
245 | {:error, _} ->
246 | {:error,
247 | "`#{inspect(raw)}` is neither a positive integer nor the special atom value `:infinity`"}
248 | end
249 | end
250 | end
251 |
252 | @doc """
253 | Parses a Module-Function-Arity tuple.
254 |
255 | Accepts it both as Elixir three-element tuple (where the first two elements are atoms, and the third is a nonnegative integer), or as string representation of the same.
256 |
257 | Will also check and ensure that this function is actually defined.
258 | """
259 | def mfa(raw) when is_binary(raw) do
260 | case string_to_term(raw) do
261 | {:ok, {module, function, arity}}
262 | when is_atom(module) and is_atom(function) and is_integer(arity) ->
263 | mfa({module, function, arity})
264 | {:ok, _other} ->
265 | {:error, "`#{inspect(raw)}`, while parseable as Elixir code, does not represent a Module-Function-Arity tuple."}
266 | {:error, reason} ->
267 | {:error, reason}
268 | end
269 | end
270 |
271 | def mfa(mfa = {module, function, arity}) when is_atom(module) and is_atom(function) and is_integer(arity) and arity >= 0 do
272 | if function_exported?(module, function, arity) do
273 | {:ok, mfa}
274 | else
275 | {:error, "function #{module}.#{function}/#{arity} does not exist."}
276 | end
277 | end
278 |
279 | def mfa(other_val) do
280 | {:error, "`#{inspect(other_val)}` is not a Module-Function-Arity tuple"}
281 | end
282 |
283 | def unquote_atom(atom) when is_atom(atom) do
284 | {:ok, atom}
285 | end
286 |
287 | def unquote_atom(aliased_atom = {:__aliases__, _, [atom]}) when is_atom(atom) do
288 | case Code.eval_quoted(aliased_atom) do
289 | {result, []} ->
290 | {:ok, result}
291 | other ->
292 | {:error, "`#{inspect(other)}` cannot be unquoted as an atom."}
293 | end
294 | end
295 |
296 | def unquote_atom(other) do
297 | {:error, "`#{inspect(other)}` cannot be unquoted as an atom."}
298 | end
299 |
300 | @doc """
301 | Parses a function.
302 |
303 | This can be a function capture, or a MFA (Module-Function-Arity) tuple, which will
304 | be transformed into the `&Module.function/arity` capture.
305 |
306 | (So in either case, you end up with a function value
307 | that you can call using the dot operator, i.e. `.()` or `.(maybe, some, args)`).
308 |
309 | ## String Contexts
310 |
311 | For contexts in which values are specified as strings, the parser only supports the MFA format.
312 | This is for security (and ease of parsing) reasons.
313 | """
314 | def function(raw) when is_binary(raw) or is_tuple(raw) do
315 | with {:ok, {module, function, arity}} <- mfa(raw),
316 | {fun, []} <- Code.eval_quoted(quote do &unquote(module).unquote(function)/unquote(arity) end) do
317 | {:ok, fun}
318 | end
319 | end
320 |
321 | def function(fun) when is_function(fun) do
322 | {:ok, fun}
323 | end
324 |
325 | def function(other) do
326 | {:error, "`#{other}` cannot be parsed as a function."}
327 | end
328 |
329 | @doc """
330 | Parses an option.
331 | An option is a 2-tuple whose first element is an atom, and the second an arbitrary term.
332 | The following terms are options:
333 | - `{:a, :b}`
334 | - `{MyApp.Module, "Hellow, world!"}`
335 | - `{:some_atom, %{[] => {1, 2, 3, 4, 5}}}`
336 | In the case a binary string was passed, this parser uses `Code.string_to_quoted` under the
337 | hood to parse the terms.
338 | It can be convenently used alongside the list parser to check for keyword list:
339 | `{:list, :option}`.
340 | """
341 | def option(raw) when is_binary(raw) do
342 | case string_to_term(raw, existing_atoms_only: true) do
343 | {:ok, term} when not is_binary(term) ->
344 | option(term)
345 |
346 | {:ok, term} ->
347 | {:error, "the term `#{inspect(term)}` cannot be parsed to an option."}
348 |
349 | {:error, _} = error ->
350 | error
351 | end
352 | end
353 |
354 | def option({key, value}) when is_atom(key) do
355 | {:ok, {key, value}}
356 | end
357 |
358 | def option(term) do
359 | {:error, "the term `#{inspect(term)}` cannot be parsed to an option."}
360 | end
361 |
362 | defp string_to_term(binary, opts \\ [existing_atoms_only: true]) when is_binary(binary) do
363 | case Code.string_to_quoted(binary, opts) do
364 | {:ok, ast} ->
365 | {:ok, ast_to_term(ast)}
366 |
367 | {:error, _} = error ->
368 | error
369 | end
370 | rescue
371 | e ->
372 | {:error, e}
373 | end
374 |
375 | defp ast_to_term(term) when is_atom(term), do: term
376 | defp ast_to_term(term) when is_integer(term), do: term
377 | defp ast_to_term(term) when is_float(term), do: term
378 | defp ast_to_term(term) when is_binary(term), do: term
379 | defp ast_to_term([]), do: []
380 | defp ast_to_term([h | t]), do: [ast_to_term(h) | ast_to_term(t)]
381 | defp ast_to_term({a, b}), do: {ast_to_term(a), ast_to_term(b)}
382 | defp ast_to_term({:{}, _place, terms}),
383 | do: terms |> Enum.map(&ast_to_term/1) |> List.to_tuple()
384 | defp ast_to_term({:%{}, _place, terms}),
385 | do: for {k, v} <- terms, into: %{}, do: {ast_to_term(k), ast_to_term(v)}
386 | defp ast_to_term(aliased = {:__aliases__, _, _}), do: Macro.expand(aliased, __ENV__)
387 | defp ast_to_term({:+, _, [number]}), do: number
388 | defp ast_to_term({:-, _, [number]}), do: -number
389 | defp ast_to_term(ast), do: raise ArgumentError, message: "invalid term `#{inspect(ast)}`"
390 | end
391 |
--------------------------------------------------------------------------------
/lib/specify/provider.ex:
--------------------------------------------------------------------------------
1 | defprotocol Specify.Provider do
2 | @moduledoc """
3 | Protocol to load configuration from a signle source.
4 |
5 | Configuration Providers implement this protocol, which consists of only one function: `load/2`.
6 | """
7 |
8 | @doc """
9 | Loads the configuration of specification `module` from the source indicated by `struct`.
10 |
11 | Its first argument is the implementation's own struct, the second argument being the configuration specification's module name.
12 | If extra information is required about the configuration specification to write a good implementation, the Reflection function `module_name.__specify__` can be used to look these up.
13 |
14 | See also `Specify.defconfig/2` and `Specify.Options`.
15 | """
16 | def load(struct, module)
17 | end
18 |
19 | defimpl Specify.Provider, for: List do
20 | def load(list, _module) do
21 | {:ok, Enum.into(list, %{})}
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/specify/provider/mix_env.ex:
--------------------------------------------------------------------------------
1 | defmodule Specify.Provider.MixEnv do
2 | @moduledoc """
3 | A Configuration Provider source based on `Mix.env()` / `Application.get_env/2`.
4 |
5 | ### Examples
6 |
7 | The following examples use the following specification for reference:
8 |
9 | defmodule Elixir.Pet do
10 | require Specify
11 | Specify.defconfig do
12 | @doc "The name of the pet"
13 | field :name, :string
14 | @doc "is it a dog or a cat?"
15 | field :kind, :atom
16 | end
17 | end
18 |
19 |
20 | """
21 | defstruct [:application, :key, optional: false]
22 |
23 | @doc """
24 | By default, will try to use `Application.get_all_env(YourConfigModule)` to fetch the source's configuration.
25 | A different application name can be used by supplying a different `application` argument.
26 |
27 | If the actual configuration is only inside one of the keys in this application, the second field `key`
28 | can also be provided.
29 |
30 |
31 | iex> Application.put_env(Elixir.Pet, :name, "Timmy")
32 | iex> Application.put_env(Elixir.Pet, :kind, "cat")
33 | iex> Pet.load(sources: [Specify.Provider.MixEnv.new()])
34 | %Pet{name: "Timmy", kind: :cat}
35 | iex> Pet.load(sources: [Specify.Provider.MixEnv.new(Elixir.Pet)])
36 | %Pet{name: "Timmy", kind: :cat}
37 |
38 | iex> Application.put_env(:second_pet, :name, "John")
39 | iex> Application.put_env(:second_pet, :kind, :dog)
40 | iex> Pet.load(sources: [Specify.Provider.MixEnv.new(:second_pet)])
41 | %Pet{name: "John", kind: :dog}
42 |
43 | """
44 | def new(application \\ nil, key \\ nil, options \\ []) do
45 | optional = options[:optional] || false
46 | %__MODULE__{application: application, key: key, optional: optional}
47 | end
48 |
49 | defimpl Specify.Provider do
50 | def load(%Specify.Provider.MixEnv{application: nil, key: nil, optional: optional}, module) do
51 | res = Enum.into(Application.get_all_env(module), %{})
52 |
53 | case {Enum.empty?(res), optional} do
54 | {true, false} ->
55 | {:error, :not_found}
56 | _ ->
57 | {:ok, res}
58 | end
59 | end
60 |
61 | def load(%Specify.Provider.MixEnv{application: application, key: nil, optional: optional}, _module) do
62 | res = Enum.into(Application.get_all_env(application), %{})
63 |
64 | case {Enum.empty?(res), optional} do
65 | {true, false} ->
66 | {:error, :not_found}
67 | _ ->
68 | {:ok, res}
69 | end
70 | end
71 |
72 | def load(%Specify.Provider.MixEnv{application: application, key: key, optional: optional}, _module) do
73 | case Application.get_env(
74 | application,
75 | key,
76 | :there_is_no_specify_configuration_in_this_application_environment!
77 | ) do
78 | map when is_map(map) ->
79 | {:ok, map}
80 |
81 | list when is_list(list) ->
82 | {:ok, Enum.into(list, %{})}
83 |
84 | :there_is_no_specify_configuration_in_this_application_environment! ->
85 | if optional do
86 | {:ok, %{}}
87 | else
88 | {:error, :not_found}
89 | end
90 |
91 | _other ->
92 | {:error, :malformed}
93 | end
94 | end
95 | end
96 | end
97 |
--------------------------------------------------------------------------------
/lib/specify/provider/process.ex:
--------------------------------------------------------------------------------
1 | defmodule Specify.Provider.Process do
2 | @moduledoc """
3 | A Configuration Provider source based on the current process' Process Dictionary.
4 |
5 | ### Examples
6 |
7 | The following examples use the following specification for reference:
8 |
9 | defmodule Elixir.Pet do
10 | require Specify
11 | Specify.defconfig do
12 | @doc "The name of the pet"
13 | field :name, :string
14 | @doc "is it a dog or a cat?"
15 | field :kind, :atom
16 | end
17 | end
18 | """
19 |
20 | defstruct [:key, optional: false]
21 |
22 | @doc """
23 | By default, will try to use `Process.get(YourModule)` to fetch the source's configuration.
24 | A different key can be used by supplying a different `key` argument.
25 |
26 | iex> Process.put(Pet, %{name: "Timmy", kind: :cat})
27 | iex> Pet.load(sources: [Specify.Provider.Process.new(Pet)])
28 | %Pet{name: "Timmy", kind: :cat}
29 |
30 | iex> Process.put(:another_pet, %{name: "John", kind: :dog})
31 | iex> Pet.load(sources: [Specify.Provider.Process.new(:another_pet)])
32 | %Pet{name: "John", kind: :dog}
33 | """
34 |
35 | def new(key \\ nil, options \\ []) do
36 | optional = options[:optional] || false
37 | %__MODULE__{key: key, optional: optional}
38 | end
39 |
40 | defimpl Specify.Provider do
41 | def load(%Specify.Provider.Process{key: nil}, module) do
42 | load(%Specify.Provider.Process{key: module}, module)
43 | end
44 |
45 | def load(%Specify.Provider.Process{key: key}, _module) do
46 | case Process.get(key, :there_is_no_specify_configuration_in_this_process_dictionary!) do
47 | map when is_map(map) ->
48 | {:ok, map}
49 |
50 | list when is_list(list) ->
51 | {:ok, Enum.into(list, %{})}
52 |
53 | :there_is_no_specify_configuration_in_this_process_dictionary! ->
54 | {:error, :not_found}
55 |
56 | _other ->
57 | {:error, :malformed}
58 | end
59 | end
60 | end
61 | end
62 |
63 | # TODO: Should we even allow this?
64 | # Looking into another process' dictionary is probably bad style, isn't it?
65 | defimpl Specify.Provider, for: PID do
66 | def load(process, module) do
67 | {:dictionary, res} = Process.info(process, :dictionary)
68 |
69 | case Access.fetch(res, module) do
70 | {:ok, map} when is_map(map) ->
71 | {:ok, map}
72 |
73 | {:ok, list} when is_list(list) ->
74 | {:ok, Enum.into(list, %{})}
75 |
76 | :error ->
77 | {:error, :not_found}
78 |
79 | _other ->
80 | {:error, :malformed}
81 | end
82 | end
83 | end
84 |
--------------------------------------------------------------------------------
/lib/specify/provider/system_env.ex:
--------------------------------------------------------------------------------
1 | defmodule Specify.Provider.SystemEnv do
2 | @moduledoc """
3 | A Configuration Provider source based on `System.get_env/2`
4 |
5 | Values will be loaded based on `\#{prefix}_\#{capitalized_field_name}`.
6 | `prefix` defaults to the capitalized name of the configuration specification module.
7 | `capitalized_field_name` is in `CONSTANT_CASE` (all-caps, with underscores as word separators).
8 |
9 | ### Examples
10 |
11 | The following examples use the following specification for reference:
12 |
13 | defmodule Elixir.Pet do
14 | require Specify
15 | Specify.defconfig do
16 | @doc "The name of the pet"
17 | field :name, :string
18 | @doc "is it a dog or a cat?"
19 | field :kind, :atom, system_env_name: "TYPE"
20 | end
21 | end
22 |
23 | Note that if a field has a different name than the environment variable you want to read from,
24 | you can add the `system_env_name:` option when specifying the field, as has been done for the `:kind` field
25 | in the example module above.
26 |
27 | iex> System.put_env("PET_NAME", "Timmy")
28 | iex> System.put_env("PET_TYPE", "cat")
29 | iex> Pet.load(sources: [Specify.Provider.SystemEnv.new()])
30 | %Pet{name: "Timmy", kind: :cat}
31 | iex> Pet.load(sources: [Specify.Provider.SystemEnv.new("PET")])
32 | %Pet{name: "Timmy", kind: :cat}
33 |
34 | iex> System.put_env("SECOND_PET_NAME", "John")
35 | iex> System.put_env("SECOND_PET_TYPE", "dog")
36 | iex> Pet.load(sources: [Specify.Provider.SystemEnv.new("SECOND_PET")])
37 | %Pet{name: "John", kind: :dog}
38 |
39 | """
40 | defstruct [:prefix, optional: false]
41 |
42 | @doc """
43 |
44 | """
45 | def new(prefix \\ nil, options \\ []) do
46 | optional = options[:optional] || false
47 | %__MODULE__{prefix: prefix, optional: optional}
48 | end
49 |
50 | defimpl Specify.Provider do
51 | def load(provider = %Specify.Provider.SystemEnv{prefix: nil}, module) do
52 | capitalized_prefix =
53 | module
54 | |> Macro.to_string()
55 | |> String.upcase()
56 |
57 | load(%Specify.Provider.SystemEnv{provider | prefix: capitalized_prefix}, module)
58 | end
59 |
60 | def load(%Specify.Provider.SystemEnv{prefix: prefix, optional: optional}, module) do
61 | full_env = System.get_env()
62 |
63 | res =
64 | Enum.reduce(module.__specify__(:field_options), %{}, fn {name, options}, acc ->
65 | capitalized_field_name = options[:system_env_name] || String.upcase(to_string(name))
66 | full_field_name = "#{prefix}_#{capitalized_field_name}"
67 |
68 | if Map.has_key?(full_env, full_field_name) do
69 | Map.put(acc, name, full_env[full_field_name])
70 | else
71 | acc
72 | end
73 | end)
74 |
75 | if res == %{} do
76 | if optional do
77 | {:ok, %{}}
78 | else
79 | {:error, :not_found}
80 | end
81 | else
82 | {:ok, res}
83 | end
84 | end
85 | end
86 | end
87 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Specify.MixProject do
2 | @source_url "https://github.com/Qqwy/elixir_specify"
3 | use Mix.Project
4 |
5 | def project do
6 | [
7 | app: :specify,
8 | version: "0.10.0",
9 | elixir: "~> 1.7",
10 | start_permanent: Mix.env() == :prod,
11 | deps: deps(),
12 | elixirc_paths: elixirc_paths(Mix.env()),
13 | description: description(),
14 | package: package(),
15 | source_url: @source_url,
16 | docs: docs()
17 | ]
18 | end
19 |
20 | # Run "mix help compile.app" to learn about applications.
21 | def application do
22 | [
23 | extra_applications: [:logger]
24 | ]
25 | end
26 |
27 | # Run "mix help deps" to learn about dependencies.
28 | defp deps do
29 | [
30 | {:ex_doc, "~> 0.19", only: [:docs], runtime: false},
31 | # Inch CI documentation quality test.
32 | {:inch_ex, ">= 0.0.0", only: [:docs]},
33 | {:stream_data, "~> 0.1", only: :test}
34 | ]
35 | end
36 |
37 | defp elixirc_paths(_), do: ["lib"]
38 |
39 | defp description do
40 | """
41 | Comfortable, Explicit, Multi-Layered and Well-Documented Specifications for all your configurations, settings and options
42 | """
43 | end
44 |
45 | defp package do
46 | # These are the default files included in the package
47 | [
48 | name: :specify,
49 | files: ["lib", "mix.exs", "README*", "LICENSE"],
50 | maintainers: ["Wiebe-Marten Wijnja/Qqwy"],
51 | licenses: ["MIT"],
52 | links: %{"GitHub" => @source_url}
53 | ]
54 | end
55 |
56 | defp docs do
57 | [
58 | main: "readme",
59 | logo: "brand/logo-thicklines-25percent.png",
60 | extras: ["README.md"]
61 | ]
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"},
3 | "earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm"},
4 | "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"},
5 | "inch_ex": {:hex, :inch_ex, "2.0.0", "24268a9284a1751f2ceda569cd978e1fa394c977c45c331bb52a405de544f4de", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
6 | "jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
7 | "makeup": {:hex, :makeup, "1.0.1", "82f332e461dc6c79dbd82fbe2a9c10d48ed07146f0a478286e590c83c52010b5", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"},
8 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"},
9 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm"},
10 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"},
11 | "stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm"},
12 | }
13 |
--------------------------------------------------------------------------------
/test/provider/mix_env_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Specify.Provider.MixEnvTest do
2 | use ExUnit.Case
3 | use ExUnitProperties
4 |
5 | doctest Specify.Provider.MixEnv
6 | end
7 |
--------------------------------------------------------------------------------
/test/provider/process_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Specify.Provider.ProcessTest do
2 | use ExUnit.Case
3 | use ExUnitProperties
4 |
5 | doctest Specify.Provider.Process
6 | end
7 |
--------------------------------------------------------------------------------
/test/provider/system_env_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Specify.Provider.SystemEnvTest do
2 | use ExUnit.Case
3 | use ExUnitProperties
4 |
5 | doctest Specify.Provider.SystemEnv
6 | end
7 |
--------------------------------------------------------------------------------
/test/specify/parsers_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Specify.ParsersTest do
2 | use ExUnit.Case
3 | use ExUnitProperties
4 |
5 | doctest Specify.Parsers
6 | alias Specify.Parsers
7 |
8 | describe "integer/1" do
9 | property "works on integers" do
10 | check all int <- integer() do
11 | assert Parsers.integer(int) === {:ok, int}
12 | end
13 | end
14 |
15 | property "works on binaries representing integers" do
16 | check all int <- integer() do
17 | str = to_string(int)
18 | assert Parsers.integer(str) === {:ok, int}
19 | end
20 | end
21 |
22 | property "fails on binaries representing integers with trailing garbage" do
23 | check all int <- integer(), postfix <- string(:printable), Integer.parse(postfix) == :error, postfix != "" do
24 | str = to_string(int)
25 | assert {:error, _} = Parsers.integer(str <> postfix)
26 | end
27 | end
28 |
29 | property "fails on non-integer terms" do
30 | check all thing <- term() do
31 | if is_integer(thing) do
32 | assert {:ok, thing} = Parsers.integer(thing)
33 | else
34 | if !is_binary(thing) || Integer.parse(thing) == :error do
35 | assert {:error, _} = Parsers.integer(thing)
36 | end
37 | end
38 | end
39 | end
40 | end
41 |
42 | describe "nonnegative_integer/1" do
43 | property "works on non-negative integers, fails on negative integers" do
44 | check all int <- integer() do
45 | if int >= 0 do
46 | assert Parsers.nonnegative_integer(int) === {:ok, int}
47 | else
48 | assert {:error, _} = Parsers.nonnegative_integer(int)
49 | end
50 | end
51 | end
52 |
53 | property "works on binaries representing non-negative integers, fails on binaries representing negative integers" do
54 | check all int <- integer() do
55 | str = to_string(int)
56 | if int >= 0 do
57 | assert Parsers.nonnegative_integer(str) === {:ok, int}
58 | else
59 | assert {:error, _} = Parsers.nonnegative_integer(str)
60 | end
61 | end
62 | end
63 |
64 | property "fails on binaries representing non-negative integers with trailing garbage" do
65 | check all int <- integer(), postfix <- string(:printable), Integer.parse(postfix) == :error, postfix != "" do
66 | str = to_string(int)
67 | assert {:error, _} = Parsers.nonnegative_integer(str <> postfix)
68 | end
69 | end
70 |
71 | property "fails on non-integer terms" do
72 | check all thing <- term() do
73 | if is_integer(thing) && thing >= 0 do
74 | assert {:ok, thing} = Parsers.nonnegative_integer(thing)
75 | else
76 | if !is_binary(thing) || Integer.parse(thing) == :error do
77 | assert {:error, _} = Parsers.nonnegative_integer(thing)
78 | end
79 | end
80 | end
81 | end
82 | end
83 |
84 | describe "positive_integer/1" do
85 | property "works on positive integers, fails on other integers" do
86 | check all int <- integer() do
87 | if int > 0 do
88 | assert Parsers.positive_integer(int) === {:ok, int}
89 | else
90 | assert {:error, _} = Parsers.positive_integer(int)
91 | end
92 | end
93 | end
94 |
95 | property "works on binaries representing positive integers, fails on binaries representing other integers" do
96 | check all int <- integer() do
97 | str = to_string(int)
98 | if int > 0 do
99 | assert Parsers.positive_integer(str) === {:ok, int}
100 | else
101 | assert {:error, _} = Parsers.positive_integer(str)
102 | end
103 | end
104 | end
105 |
106 | property "fails on binaries representing non-negative integers with trailing garbage" do
107 | check all int <- integer(), postfix <- string(:printable), Integer.parse(postfix) == :error, postfix != "" do
108 | str = to_string(int)
109 | assert {:error, _} = Parsers.positive_integer(str <> postfix)
110 | end
111 | end
112 |
113 | property "fails on non-integer terms" do
114 | check all thing <- term() do
115 | if is_integer(thing) && thing > 0 do
116 | assert {:ok, thing} = Parsers.positive_integer(thing)
117 | else
118 | if !is_binary(thing) || Integer.parse(thing) == :error do
119 | assert {:error, _} = Parsers.positive_integer(thing)
120 | end
121 | end
122 | end
123 | end
124 | end
125 |
126 | describe "float/1" do
127 | property "works on floats" do
128 | check all float <- float() do
129 | assert Parsers.float(float) === {:ok, float}
130 | end
131 | end
132 |
133 | property "works on integers" do
134 | check all int <- integer() do
135 | assert Parsers.float(int) === {:ok, int * 1.0}
136 | end
137 | end
138 |
139 | property "works on binaries representing floats" do
140 | check all float <- float() do
141 | str = to_string(float)
142 | assert Parsers.float(str) === {:ok, float}
143 | end
144 | end
145 |
146 | property "fails on binaries representing floats with trailing garbage" do
147 | check all float <- float(), postfix <- string(:printable), Float.parse(postfix) == :error, postfix != "" do
148 | str = to_string(float)
149 | assert {:error, _} = Parsers.float(str <> postfix)
150 | end
151 | end
152 |
153 | property "works on binaries representing integers" do
154 | check all int <- integer() do
155 | str = to_string(int)
156 | assert Parsers.float(str) === {:ok, 1.0 * int}
157 | end
158 | end
159 |
160 | property "fails on non-integer, non-float terms" do
161 | check all thing <- term() do
162 | if(is_float(thing) or is_integer(thing)) do
163 | assert {:ok, 1.0 * thing} == Parsers.float(thing)
164 | else
165 | if !is_binary(thing) || Float.parse(thing) == :error do
166 | assert {:error, _} = Parsers.float(thing)
167 | end
168 | end
169 | end
170 | end
171 | end
172 |
173 | describe "nonnegative_float/1" do
174 | property "works on non-negative floats, fails on negative floats" do
175 | check all float <- float() do
176 | if float >= 0 do
177 | assert Parsers.nonnegative_float(float) === {:ok, float}
178 | else
179 | assert {:error, _} = Parsers.nonnegative_float(float)
180 | end
181 | end
182 | end
183 |
184 | property "works on non-negative integers, fails on negative integers" do
185 | check all int <- integer() do
186 | if int >= 0 do
187 | assert Parsers.nonnegative_float(int) === {:ok, int * 1.0}
188 | else
189 | assert {:error, _} = Parsers.nonnegative_float(int)
190 | end
191 | end
192 | end
193 |
194 | property "works on binaries representing non-negative floats, fails on binaries representing negative floats" do
195 | check all float <- float() do
196 | str = to_string(float)
197 | if float >= 0 do
198 | assert Parsers.nonnegative_float(str) === {:ok, float}
199 | else
200 | assert {:error, _} = Parsers.nonnegative_float(str)
201 | end
202 | end
203 | end
204 |
205 | property "fails on binaries representing floats with trailing garbage" do
206 | check all float <- float(), postfix <- string(:printable), Float.parse(postfix) == :error, postfix != "" do
207 | str = to_string(float)
208 | assert {:error, _} = Parsers.nonnegative_float(str <> postfix)
209 | end
210 | end
211 |
212 | property "works on binaries representing non-negative integers, fails on binaries representing negative integers" do
213 | check all int <- integer() do
214 | str = to_string(int)
215 | if int >= 0 do
216 | assert Parsers.nonnegative_float(str) === {:ok, 1.0 * int}
217 | else
218 | assert {:error, _} = Parsers.nonnegative_float(str)
219 | end
220 | end
221 | end
222 |
223 | property "fails on non-float, non-integer terms" do
224 | check all thing <- term() do
225 | if (is_float(thing) or is_integer(thing)) and thing >= 0 do
226 | assert {:ok, 1.0 * thing} == Parsers.nonnegative_float(thing)
227 | else
228 | if !is_binary(thing) || Float.parse(thing) == :error do
229 | assert {:error, _} = Parsers.nonnegative_float(thing)
230 | end
231 | end
232 | end
233 | end
234 | end
235 |
236 | describe "positive_float/1" do
237 | property "works on positive floats, fails on negative floats" do
238 | check all float <- float() do
239 | if float > 0 do
240 | assert Parsers.positive_float(float) === {:ok, float}
241 | else
242 | assert {:error, _} = Parsers.positive_float(float)
243 | end
244 | end
245 | end
246 |
247 | property "works on positive integers, fails on negative integers" do
248 | check all int <- integer() do
249 | if int > 0 do
250 | assert Parsers.positive_float(int) === {:ok, int * 1.0}
251 | else
252 | assert {:error, _} = Parsers.positive_float(int)
253 | end
254 | end
255 | end
256 |
257 | property "works on binaries representing positive floats, fails on binaries representing negative floats" do
258 | check all float <- float() do
259 | str = to_string(float)
260 | if float > 0 do
261 | assert Parsers.positive_float(str) === {:ok, float}
262 | else
263 | assert {:error, _} = Parsers.positive_float(str)
264 | end
265 | end
266 | end
267 |
268 | property "fails on binaries representing floats with trailing garbage" do
269 | check all float <- float(), postfix <- string(:printable), Float.parse(postfix) == :error, postfix != "" do
270 | str = to_string(float)
271 | assert {:error, _} = Parsers.positive_float(str <> postfix)
272 | end
273 | end
274 |
275 | property "works on binaries representing positive integers, fails on binaries representing negative integers" do
276 | check all int <- integer() do
277 | str = to_string(int)
278 | if int > 0 do
279 | assert Parsers.positive_float(str) === {:ok, 1.0 * int}
280 | else
281 | assert {:error, _} = Parsers.positive_float(str)
282 | end
283 | end
284 | end
285 |
286 | property "fails on non-float, non-integer terms" do
287 | check all thing <- term() do
288 | if (is_float(thing) or is_integer(thing)) and thing >= 0 do
289 | assert {:ok, 1.0 * thing} == Parsers.nonnegative_float(thing)
290 | else
291 | if !is_binary(thing) || Float.parse(thing) == :error do
292 | assert {:error, _} = Parsers.nonnegative_float(thing)
293 | end
294 | end
295 | end
296 | end
297 | end
298 |
299 | describe "string/1" do
300 | property "works on binaries" do
301 | check all bin <- string(:printable) do
302 | assert {:ok, bin} = Parsers.string(bin)
303 | end
304 | end
305 |
306 | property "works on charlists" do
307 | check all bin <- string(:printable) do
308 | chars = to_charlist(bin)
309 | assert {:ok, bin} = Parsers.string(chars)
310 | end
311 | end
312 |
313 | property "works on terms that implement String.Chars" do
314 | check all thing <-
315 | one_of([
316 | integer(),
317 | string(:printable),
318 | binary(),
319 | float(),
320 | boolean(),
321 | atom(:alphanumeric)
322 | ]) do
323 | assert {:ok, "#{thing}"} == Parsers.string(thing)
324 | end
325 | end
326 | end
327 |
328 | describe "boolean/1" do
329 | property "works on booleans" do
330 | check all bool <- boolean() do
331 | assert {:ok, bool} == Parsers.boolean(bool)
332 | end
333 | end
334 |
335 | property "works on strings representing booleans" do
336 | check all bool <- boolean() do
337 | str = to_string(bool)
338 | assert {:ok, bool} == Parsers.boolean(str)
339 | end
340 | end
341 |
342 | property "does not work on non-boolean terms" do
343 | check all thing <- term() do
344 | if is_boolean(thing) do
345 | assert {:ok, thing} == Parsers.boolean(thing)
346 | else
347 | assert {:error, _} = Parsers.boolean(thing)
348 | end
349 | end
350 | end
351 | end
352 |
353 | describe "atom/1 and unsafe_atom/1" do
354 | property "works on atoms" do
355 | check all atom <- one_of([atom(:alphanumeric), atom(:alias)]) do
356 | assert {:ok, atom} == Parsers.atom(atom)
357 | assert {:ok, atom} == Parsers.unsafe_atom(atom)
358 | end
359 | end
360 |
361 | property "Works on strings" do
362 | check all atom <- one_of([atom(:alphanumeric), atom(:alias)]) do
363 | str = to_string(atom)
364 | assert {:ok, atom} == Parsers.atom(str)
365 | assert {:ok, atom} == Parsers.unsafe_atom(str)
366 | end
367 | end
368 |
369 | test "atom/1 raises on non-existent atom" do
370 | assert {:error, _} = Parsers.atom("this_does_not_exist_as_atom")
371 | assert {:error, _} = Parsers.atom("This.Module.Does.Not.Exist.Either")
372 | end
373 |
374 | test "unsafe_atom/1 does noton non-existent atom" do
375 | assert {:ok, :this_does_not_exist_as_atom2} =
376 | Parsers.unsafe_atom("this_does_not_exist_as_atom2")
377 |
378 | assert {:ok, This.Module.Does.Not.Exist.Either2} =
379 | Parsers.unsafe_atom("Elixir.This.Module.Does.Not.Exist.Either2")
380 | end
381 | end
382 |
383 | describe "list/2" do
384 | property "works on lists of atoms" do
385 | check all list <- list_of(atom(:alphanumeric)) do
386 | assert {:ok, list} == Parsers.list(list, &Parsers.atom/1)
387 | end
388 | end
389 |
390 | property "works on strings representing lists of atoms" do
391 | check all list <- list_of(atom(:alphanumeric)) do
392 | str = inspect(list, limit: :infinity)
393 | assert {:ok, list} == Parsers.list(str, &Parsers.atom/1)
394 | end
395 | end
396 |
397 | property "works on lists of integers" do
398 | check all list <- list_of(integer()) do
399 | assert {:ok, list} == Parsers.list(list, &Parsers.integer/1)
400 | end
401 | end
402 |
403 | property "works on strings representing lists of integers" do
404 | check all list <- list_of(integer()) do
405 | str = inspect(list, limit: :infinity)
406 | assert {:ok, list} == Parsers.list(str, &Parsers.integer/1)
407 | end
408 | end
409 |
410 | property "works on keyword lists (list of options)" do
411 | check all list <- list_of(tuple({atom(:alphanumeric), supported_terms_generator()})) do
412 | str = inspect(list, limit: :infinity)
413 | assert {:ok, list} == Parsers.list(str, &Parsers.option/1)
414 | end
415 | end
416 |
417 | property "works on strings representing lists of arbitrary terms" do
418 | check all list <- list_of(supported_terms_generator()) do
419 | str = inspect(list, limit: :infinity)
420 | assert {:ok, list} == Parsers.list(str, &Parsers.term/1)
421 | end
422 | end
423 | end
424 |
425 | describe "timeout/1" do
426 | test "works on `:infinity` (both as atom and as binary)" do
427 | assert Parsers.timeout(:infinity) === {:ok, :infinity}
428 | assert Parsers.timeout("infinity") === {:ok, :infinity}
429 | end
430 |
431 | property "works on positive integers, fails on other integers" do
432 | check all int <- integer() do
433 | if int > 0 do
434 | assert Parsers.timeout(int) === {:ok, int}
435 | else
436 | assert {:error, _} = Parsers.timeout(int)
437 | end
438 | end
439 | end
440 |
441 | property "works on binaries representing positive integers, fails on binaries representing other integers" do
442 | check all int <- integer() do
443 | str = to_string(int)
444 | if int > 0 do
445 | assert Parsers.timeout(str) === {:ok, int}
446 | else
447 | assert {:error, _} = Parsers.timeout(str)
448 | end
449 | end
450 | end
451 |
452 | property "fails on binaries representing non-negative integers with trailing garbage" do
453 | check all int <- integer(), postfix <- string(:printable), Integer.parse(postfix) == :error, postfix != "" do
454 | str = to_string(int)
455 | assert {:error, _} = Parsers.integer(str <> postfix)
456 | end
457 | end
458 |
459 | property "fails on non-integer, non-`:infinity` terms" do
460 | check all thing <- term() do
461 | if thing == :infinity || (is_integer(thing) && thing > 0) do
462 | assert {:ok, thing} = Parsers.timeout(thing)
463 | else
464 | if !is_binary(thing) || Integer.parse(thing) == :error do
465 | assert {:error, _} = Parsers.timeout(thing)
466 | end
467 | end
468 | end
469 | end
470 | end
471 |
472 | describe "mfa/1" do
473 | property "works on MFA tuples of existing functions (and binaries of the same)" do
474 | existing_mfas =
475 | Enum.__info__(:functions)
476 | |> Enum.map(fn {fun, arity} -> constant({Enum, fun, arity}) end)
477 | check all mfa <- one_of(existing_mfas) do
478 | assert {:ok, fun} = Parsers.mfa(mfa)
479 | assert {:ok, fun} = Parsers.mfa(inspect(mfa))
480 | end
481 | end
482 |
483 | property "Fails on MFA tuples of non-existing functions" do
484 | check all module <- atom(:alphanumeric), fun <- atom(:alphanumeric), arity <- integer(), !function_exported?(module, fun, arity) do
485 | assert {:error, res} = Parsers.mfa({module, fun, abs(arity)})
486 | end
487 | end
488 | end
489 |
490 | describe "function/1" do
491 | property "works on MFA tuples of existing functions (and binaries of the same)" do
492 | existing_mfas =
493 | Enum.__info__(:functions)
494 | |> Enum.map(fn {fun, arity} -> constant({Enum, fun, arity}) end)
495 | check all mfa <- one_of(existing_mfas) do
496 | assert {:ok, fun} = Parsers.mfa(mfa)
497 | assert {:ok, fun} = Parsers.mfa(inspect(mfa))
498 | end
499 | end
500 |
501 | property "Fails on MFA tuples of non-existing functions" do
502 | check all module <- atom(:alphanumeric), fun <- atom(:alphanumeric), arity <- integer(), !function_exported?(module, fun, arity) do
503 | assert {:error, res} = Parsers.mfa({module, fun, abs(arity)})
504 | end
505 | end
506 | end
507 |
508 | describe "option/1" do
509 | property "works on option of arbitrary term" do
510 | check all option <- tuple({atom(:alphanumeric), supported_terms_generator()}) do
511 | assert {:ok, option} == Parsers.option(option)
512 | end
513 | end
514 |
515 | property "works on strings representing options of arbitrary terms" do
516 | check all option <- tuple({atom(:alphanumeric), supported_terms_generator()}) do
517 | str = inspect(option, limit: :infinity)
518 | assert {:ok, option} == Parsers.option(str)
519 | end
520 | end
521 | end
522 |
523 | defp supported_terms_generator(max_depth \\ 2) do
524 | generators = [
525 | boolean(),
526 | float(),
527 | integer(),
528 | string(:ascii),
529 | string(:alphanumeric),
530 | string(:printable),
531 | ]
532 |
533 | generators
534 | |> maybe_add_tuple_generator(max_depth)
535 | |> maybe_add_map_generator(max_depth)
536 | |> one_of()
537 | end
538 |
539 | defp maybe_add_tuple_generator(generators, 0) do
540 | generators
541 | end
542 |
543 | defp maybe_add_tuple_generator(generators, max_depth) do
544 | [
545 | (for _ <- 1..Enum.random(1..5), do: supported_terms_generator(max_depth - 1))
546 | |> List.to_tuple()
547 | |> tuple
548 | | generators]
549 | end
550 |
551 | defp maybe_add_map_generator(generators, 0) do
552 | generators
553 | end
554 |
555 | defp maybe_add_map_generator(generators, max_depth) do
556 | [
557 | map_of(
558 | supported_terms_generator(max_depth - 1),
559 | supported_terms_generator(max_depth - 1),
560 | max_length: 5
561 | )
562 | | generators
563 | ]
564 | end
565 | end
566 |
--------------------------------------------------------------------------------
/test/specify_test.exs:
--------------------------------------------------------------------------------
1 | defmodule SpecifyTest do
2 | use ExUnit.Case
3 | use ExUnitProperties
4 | import ExUnit.CaptureIO
5 | import ExUnit.CaptureLog
6 |
7 | doctest Specify
8 |
9 | describe "Simple examples of Specify.defconfig/2" do
10 | defmodule BasicSpecifyExample do
11 | require Specify
12 |
13 | Specify.defconfig do
14 | @doc "a field with a default"
15 | field(:name, :string, default: "Jabberwocky")
16 | @doc "A required field"
17 | field(:age, :integer)
18 | @doc "A compound parsers test."
19 | field(:colors, {:list, :atom}, default: [])
20 | end
21 | end
22 |
23 | test "Basic configuration works without glaring problems" do
24 | assert Specify.load(BasicSpecifyExample, explicit_values: [age: 42]) == %BasicSpecifyExample{
25 | name: "Jabberwocky",
26 | age: 42,
27 | colors: []
28 | }
29 |
30 | assert BasicSpecifyExample.load(explicit_values: [age: 43]) == %BasicSpecifyExample{
31 | name: "Jabberwocky",
32 | age: 43,
33 | colors: []
34 | }
35 |
36 | assert Specify.load_explicit(BasicSpecifyExample, age: 42) == %BasicSpecifyExample{
37 | name: "Jabberwocky",
38 | age: 42,
39 | colors: []
40 | }
41 |
42 | assert BasicSpecifyExample.load_explicit(age: 44, colors: [:red, :green]) ==
43 | %BasicSpecifyExample{name: "Jabberwocky", age: 44, colors: [:red, :green]}
44 |
45 | assert_raise(Specify.MissingRequiredFieldsError, fn ->
46 | Specify.load(BasicSpecifyExample)
47 | end)
48 |
49 | assert_raise(Specify.MissingRequiredFieldsError, fn ->
50 | Specify.load_explicit(BasicSpecifyExample, [])
51 | end)
52 | end
53 |
54 | test "compound parsers are used correctly" do
55 | assert %BasicSpecifyExample{colors: [:red, :green, :blue]} =
56 | BasicSpecifyExample.load_explicit(age: 22, colors: "[:red, :green, :blue]")
57 |
58 | assert %BasicSpecifyExample{colors: [:red, :cyan, :yellow]} =
59 | BasicSpecifyExample.load_explicit(age: 22, colors: [:red, "cyan", :yellow])
60 | end
61 |
62 | test "Warnings are shown when defining a configuration with missing doc strings" do
63 | assert capture_io(:stderr, fn ->
64 | defmodule MissingDocs do
65 | require Specify
66 |
67 | Specify.defconfig do
68 | field(:name, :string, default: "Slatibartfast")
69 | end
70 | end
71 | end) =~
72 | "Missing documentation for configuration field `name`. Please add it by adding `@doc \"field documentation here\"` above the line where you define it."
73 | end
74 |
75 | test "Reflection is in place" do
76 | assert MapSet.new([:name, :age, :colors]) == BasicSpecifyExample.__specify__(:field_names)
77 | assert %{name: "Jabberwocky", colors: []} == BasicSpecifyExample.__specify__(:defaults)
78 | assert MapSet.new([:age]) == BasicSpecifyExample.__specify__(:required_fields)
79 |
80 | assert %{
81 | name: &Specify.Parsers.string/1,
82 | age: &Specify.Parsers.integer/1,
83 | colors: {&Specify.Parsers.list/2, &Specify.Parsers.atom/1}
84 | } == BasicSpecifyExample.__specify__(:parsers)
85 | end
86 |
87 | for provider <- [Specify.Provider.SystemEnv, Specify.Provider.MixEnv, Specify.Provider.Process] do
88 | @provider provider
89 | test "Specify shows warnings on missing (required) sources for #{@provider}" do
90 | res = capture_log(fn ->
91 | defmodule ConfigWithRequiredSource do
92 | require Specify
93 |
94 | Specify.defconfig do
95 | @doc "A name"
96 | field(:name, :string)
97 | end
98 | end
99 |
100 | conf = ConfigWithRequiredSource.load_explicit(%{name: "Floof"}, sources: [@provider])
101 | assert %{name: "Floof"} = conf
102 | end)
103 |
104 | assert res =~ "While loading the configuration `SpecifyTest.ConfigWithRequiredSource`, the source `%#{inspect(@provider)}"
105 | assert res =~ "could not be found."
106 | end
107 |
108 | test "Specify does not show warnings on missing (optional) sources for #{@provider}" do
109 | res = capture_log(fn ->
110 | defmodule ConfigWithOptionalSource do
111 | require Specify
112 |
113 | Specify.defconfig [sources: [%Specify.Provider.SystemEnv{optional: true}]] do
114 | @doc "A name"
115 | field(:name, :string)
116 | end
117 | end
118 |
119 | conf = ConfigWithOptionalSource.load_explicit(%{name: "Floof"}, sources: [@provider])
120 | assert %{name: "Floof"} = conf
121 | end)
122 |
123 | assert res == ""
124 | end
125 | end
126 |
127 | end
128 |
129 | describe "parsers are properly called" do
130 | defmodule ParsingExample do
131 | require Specify
132 |
133 | Specify.defconfig do
134 | @doc false
135 | field(:size, :integer, default: "42")
136 | end
137 | end
138 |
139 | test "Parser is called with default" do
140 | assert ParsingExample.load() == %ParsingExample{size: 42}
141 | end
142 |
143 | property "parser is called with value" do
144 | check all thing <- term() do
145 | if is_integer(thing) do
146 | assert %ParsingExample{size: thing} = ParsingExample.load_explicit(size: thing)
147 | else
148 | assert_raise(Specify.ParsingError, fn ->
149 | ParsingExample.load_explicit(size: thing)
150 | end)
151 | end
152 | end
153 | end
154 |
155 | property "parser failure results in (custom) error" do
156 | defmodule MyCustomError do
157 | defexception [:message]
158 | end
159 |
160 | check all thing <- term(), !is_integer(thing) do
161 | assert_raise(MyCustomError, fn ->
162 | ParsingExample.load_explicit([size: thing], parsing_error: MyCustomError)
163 | end)
164 | end
165 | end
166 | end
167 |
168 | describe "multi-parser is properly called" do
169 | defmodule MultiParserExample do
170 | require Specify
171 |
172 | Specify.defconfig do
173 | @doc false
174 | field(:value, [:integer, :float, :boolean], default: "13.37")
175 | end
176 | end
177 |
178 | test "Parser is called with default" do
179 | assert MultiParserExample.load() == %MultiParserExample{value: 13.37}
180 | end
181 |
182 | property "parser is called with value" do
183 | check all thing <- term() do
184 | if is_integer(thing) or is_float(thing) or is_boolean(thing) do
185 | assert %MultiParserExample{value: thing} = MultiParserExample.load_explicit(value: thing)
186 | else
187 | assert_raise(Specify.ParsingError, fn ->
188 | MultiParserExample.load_explicit(value: thing)
189 | end)
190 | end
191 | end
192 | end
193 | end
194 | end
195 |
--------------------------------------------------------------------------------
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | # Used in multiple modules' doctests
2 | defmodule Pet do
3 | require Specify
4 | Specify.defconfig do
5 | @doc "The name of the pet"
6 | field :name, :string
7 | @doc "is it a dog or a cat?"
8 | field :kind, :atom, system_env_name: "TYPE"
9 | end
10 | end
11 |
12 | ExUnit.start()
13 |
14 | # We require some atoms to be defined for the doctests
15 | _some_animal_kinds = [:cat, :dog, :bird, :fish]
16 |
--------------------------------------------------------------------------------